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
- React's ContextAPI to provide shared state across multiple components.
- Async Storage to set and get async data.
- React Navigation for our navigation and as a top level insertion point to hydrate our shared state (context).
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,
};
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.
-
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}`);
}
};
-
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;
}
}
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>
);
};
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]);
}
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
And there we go!
Let me know if you have any questions in the comments below.
Top comments (0)