My default approach to state management when building React applications is based on this excellent article. In short, I try to leverage local component state as much as possible. I use a dedicated library (like TanStack Query) for storing server data. I only resort to global state management (like Redux) only when really necessary.

Persisting state between component mounts

There are some use cases that are not covered by this approach, though. A good example is a tabbed UI where each tab has some input controls. As the user types into these controls and than switches between the tabs, it may sometime be desirable to persist the state between the tab switches. However, if we simply stored the user input as local state of each tab component, it wouldn’t be persisted, as the component would get umounted upon tab switching.

Take a look at the example here. If you type into the input in the Mario component, than change the tab to Luigi and than come back, your input disappears. This is because Mario component got unmounted and its state was lost.

Let’s review some of the possible approaches to persisting the state between the tab switches:

  • Store the state in the parent or in an ancestor (e.g. in the MarioTabs component). This would solve the problem. However, in this approach we abandon clear boundaries between components. Internal state of a child component is exposed to the parent component, which shouldn’t really know anything about it. What’s more, it has performance impact - any update to Mario‘s state would lead to rerendering of the whole component tree.
  • Use a state management library, e.g. Redux. Here again we would be exposing local component state to other components. State management libraries allow the consumers of the state to react to changes in the store. We don’t need this functionality at all for our use case. What’s more, state management libraries may require some boilerplate and may lead to performance issues.

Instead, I’d like to propose a very simple approach in which the state is stored in a module-defined variable. This approach is similar to using a state management library, but without the ability to notify the consumers about the state being updated. It solves exactly one problem - persisting the state between remounts and nothing else.

Implementation - usePersistedState hook

In this approach we create a new hook called usePersistedState. It has a similar signature to the regular useState hook, but it additionally accepts an identifier - the key under which we will store this piece of state. The implementation of this hook is very straightforward - it wraps a regular useState hook. On top of that, every time the state is changed, it updates the global dictionary where various pieces of state are stored under different keys. Finally, the global dictionary is read to get the initial state for the useState hook.

1
2
3
4
5
6
7
8
9
10
11
12
13
const Store = new Map<string, unknown>();

export const usePersistedState = <S>(
id: string,
initialState: S | (() => S)
) => {
const stateFromStore = Store.has(id) ? (Store.get(id) as S) : undefined;
const [state, dispatch] = useState<S>(stateFromStore ?? initialState);
useEffect(() => {
Store.set(id, state);
}, [state, id]);
return [state, dispatch] as const;
};

And that’s it. This simple solution is all you need to persist state local component state between component remounts. The id parameter should uniquely identify the state.

One caveat of this approach is that you have to be careful when working with multiple instances of a given component. In such case you’ll need to make sure that each instance gets a different identifier.

Disclaimer: as I begun writing this article, I realized that there is already a library that provides a similar functionality and also uses the same name (usePersistedState) as I did for my custom hook. Still, I decided to write the article to document my thought process. You’d be probably better off using the library then my simple implementation.

Clearing the state

One caveat of this approach is that sometimes you may need to clear the state. One obvious example is unit tests - if you forget to clear the state after each test, you may get surprising results. Clearing the state is trivial - it’s sufficient to remove the value stored under given identifier.

1
2
3
export const removeFromState = (id: string) => {
Store.delete(id);
};

Another reason for clearing the state may be if MarioTabs lived in another container component. In some scenarios we may want to clear the state when the container component is unmounted (e.g. if the container is a closeable tab and the user explicitly closes the tab).

Summary

This article demonstrates a minimal approach for persisting state between component remounts.

There are many ways in which you can extend this approach if needed:

  • implement a persisted version of the useReducer hook
  • allow using Local Storage as the Store, next to the in-memory store
  • introduce a another parameter - containerId - and allow clearing ąll entries within given container

Let me know what you think in the comments.