DEV Community

Cover image for Async state hydration in React Native using ContextAPI
Mark
Mark

Posted on • Updated on

Async state hydration in React Native using ContextAPI

So you've built a React Native app and want to keep your state fresh...

There comes a point in a React Native app where you'll likely want to keep data stored on the device's storage. For example, you may want to store a user's data aync so that you can keep it fresh across the app regardless of network state. We can store state using react-native-async-storeage to set and get asynchronous, device stored data into our app's state. But how can we persist and hydrate state that's shared across multiple components using ContextAPI? Let me show you!

Our stack

Before we hydrate a Context's state, let's set the key's and default values for the state. This will be the keys to the data that we will store in Async Storage.
Let's use a simple UserContext in this example, starting by creating a userState with initial state:

// in userState.js
export const INITIAL_USER_STATE = {
  user: null,
};
Enter fullscreen mode Exit fullscreen mode

In order to use the userState key's in async storage, we need to write a few util functions to get the usetState's values from the async storage.

  1. hydrateState
/**
 * @param {Object} contextInitialState
 * @param {ReactUseStateSetter} stateSetter
 * @description Expects any Context's initial state and local contextStateSetter to hydrate the async
 * state. The async state for the keys is fetched and loaded in as a object, using getAsyncStateObject,
 * and the respective context state is set. The state should meant to be sent to the context's provider
 * as prop `asyncState` which hydrates the Context.
 */
export const hydrateState = async (contextInitialState, stateSetter) => {
  try {
    // Get the async values for all of the keys from this Context's initial state
    let asyncStateObject = await getAsyncStateObject(Object.keys(contextInitialState));
    // Ensure that default state is not overwritten by null Async State values
    for (let [key, value] of Object.entries(asyncStateObject)) {
      // If async storage does not have a value for it, set it to the state's initial value.
      if (value === null) {
        asyncStateObject[key] = contextInitialState[key];
      }
    }
    stateSetter(asyncStateObject);
  } catch (error) {
    console.error(error, `Failed to hydrate state for keys ${stateKeysArray}`);
  }
};
Enter fullscreen mode Exit fullscreen mode
  1. getAsyncStateObject

/**
 * @param {Array[String]} asyncStorageKeys : List of keys to get from AsyncStorage
 * @returns {Object} asyncDataObject: Object representation of key value pairs from sync storage
 */
export async function getAsyncStateObject(asyncStorageKeys) {
  // Get all of the values from async storage
  try {
    const asyncKeyValuePairs = await AsyncStorage.multiGet(asyncStorageKeys);
    //  Reduce all of the pairs into a single object with keys and parsed values
    const asyncDataObject = asyncKeyValuePairs.reduce(
      (mapping, [key, value]) => ({ ...mapping, [key]: JSON.parse(value) }),
      {},
    );
    return asyncDataObject;
  } catch (error) {
    console.error(error, `Failed to build state for keys ${asyncStorageKeys}`);
    throw error;
  }
}

Enter fullscreen mode Exit fullscreen mode

Now that we have a way to get async state values for userState's keys, let's use that in our app's navigation insertion point to get the userState context hydrated.

//... imports
// App.js

export const App = () => {
  const [isReady, setIsReady] = useState(false);
  const [navigationState, setNavigationState] = useState();
  const [userAsyncState, setUserAsyncState] = useState();
  /**
   * @description Effect that ensures Navigation and ContextAPI state is hydrated from Async Storage.
   * First, hydrates all Context state to load in application, then hydrates Navigation state.
   * See https://reactnavigation.org/docs/state-persistence/ for nav guidance.
   */
  useEffect(() => {
    const restoreState = async () => {
      try {
        // Hydrate all Context's state. See hydrateState.
        await Promise.all([
          hydrateState(INITIAL_USER_STATE, setUserAsyncState),
          // Add any other context you want to hydrate here
        ]);

        const initialUrl = await Linking.getInitialURL();
        // Only restore navigationState if there's no deep link and we're not on web
        if (Platform.OS !== 'web' && initialUrl == null) {
          // The async navigation state, as a raw string
          const savedStateString = await AsyncStorage.getItem(PERSISTENCE_KEY);
          // Parse the raw string into a Javascript object
          const navigationState = savedStateString
            ? JSON.parse(savedStateString)
            : undefined;
          if (navigationState !== undefined)
            setNavigationState(navigationState);
        }
      } finally {
        // Tell the app the navigation and async state is ready
        setIsReady(true);
      }
    };

    if (!isReady) {
      restoreState();
    }
  }, [isReady]);

  // View that is returned when the app navigation is not ready/loading
  if (!isReady) return <Loading />;

  return (
    // Our Context with the userAsyncState we retrieve from async storage.
    <UserState asyncState={userAsyncState}>
      <NavigationContainer
        initialState={navigationState}
        onStateChange={(updatedState) =>
          AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(updatedState))
        }
      >
        <StackNavigator><StackNavigator />
      </NavigationContainer>
    </UserState>
  );
};
Enter fullscreen mode Exit fullscreen mode

We're almost there! To finish the hydration, we need to handle the async state incoming from the async storage in our actual context. We'll need one more helper to achieve this, here we'll write an Effect that will do just that!

import { useEffect } from 'react';
// Your dispatcher types
import { LOADING, SET_STATE } from './types';
/**
 *
 * @param {Object} asyncState
 * @param {React.Dispatch} dispatch: Dispatcher resulting from use of `useReducer` hook
 * @description Dispatches SET_STATE and LOADING to any Context given a object of state to set
 * a context state.
 */
export function useAsyncStateEffect(asyncState, dispatch) {
  function dispatchState() {
    dispatch({ type: LOADING, payload: true });
    if (!asyncState) {
      console.error(
        `Could not update state for context dispatcher ${dispatch} due to asyncState being undefined or null. `,
      );
    } else {
      dispatch({ type: SET_STATE, payload: asyncState });
    }
    dispatch({ type: LOADING, payload: false });
  }
  // Dispatch the state, every time the asyncState updates
  useEffect(() => {
    dispatchState();
  }, [asyncState]);
}

Enter fullscreen mode Exit fullscreen mode

Finally, in our userState Context, we can handle the asyncState with this effect!

//...
export const UserState = ({ children, asyncState }) => {
  const [state, dispatch] = useReducer(userReducer, INITIAL_USER_STATE);
 // Load the asyncState into this context's state
 useAsyncStateEffect(asyncState, dispatch);
//... rest of state
Enter fullscreen mode Exit fullscreen mode

And there we go!
Let me know if you have any questions in the comments below.

Top comments (0)