Keeping React State and localStorage in sync
— React — 3 min read
Nowadays, I use React function components and the hooks API a lot. One common use case that naturally arises is to keep React state and local storage in sync. Though we're building client-side applications, we still want them to work when the user refreshes the page or comes back to the application after some time.
This is where the localStorage comes in that lets us store some React state and re-hydrate it into our application on page load. To avoid repeating ourselves with localStorage code, we can create a custom hook that lets us use localStorage as a React state store. The respective interface should look the same as a regular react useState
hook, a tuple of the current state, and a dispatch function that lets us update the state.
1import { Dispatch, SetStateAction } from 'react';2
3type PersistedState<T> = [T, Dispatch<SetStateAction<T>>];4
5export { PersistedState };
Inside our custom hook, we can use the useState
hook to create a state store that either returns the value from the localStorage or the default value. We can then use the useEffect
hook to update the localStorage item when the state changes:
1import { Dispatch, SetStateAction, useEffect, useState } from 'react';2
3type PersistedState<T> = [T, Dispatch<SetStateAction<T>>];4
5function usePersistedState<T>(defaultValue: T, key: string): PersistedState<T> {6 const [value, setValue] = useState<T>(() => {7 const value = window.localStorage.getItem(key);8
9 return value ? (JSON.parse(value) as T) : defaultValue;10 });11
12 useEffect(() => {13 window.localStorage.setItem(key, JSON.stringify(value));14 }, [key, value]);15
16 return [value, setValue];17}18
19export { usePersistedState };
The usePersistedState hook works like a regular useState
hook, except it persists to and restores the state from a localStorage item. On the first render, the hook will try to restore the state from localStorage. If there is no value in localStorage, the hook will return the default value.
You might notice the typescript cast in the return call (JSON.parse(value) as T
), which can lead to potential schema issues. Let's take this example:
1import React from 'react';2import { usePersistedState } from '.';3
4interface User {5 name: string;6}7
8const App = () => {9 const [user] = usePersistedState<User>({ name: 'John' }, 'user');10
11 return (12 <div>13 <p>{user.name}</p>14 </div>15 );16};17
18export { App };
Now we want to change the user interface to something like:
interface User { firstname: string; lastname: string;}
The item we stored in a user's localStorage and our React state don't follow the same schema anymore. There might be users out there that have an outdated item in their localStorage persisted. This will very likely lead to a runtime error at least to unintended behavior. One way to avoid this is to use some runtime-type system like io-ts to validate schema correctness. The following example shows how to use io-ts to validate our new schema:
import * as t from 'io-ts';import React from 'react';import { usePersistedState } from '.';import { isRight } from 'fp-ts/lib/Either';
const tUser = t.type({ firstname: t.string, lastname: t.string,});
type User = t.TypeOf<typeof tUser>;
const App = () => { const [user] = usePersistedState<User>({ firstname: 'John', lastname: 'Doe' }, 'user');
return isRight(tUser.decode(user)) ? ( <div> <p>{user.name}</p> </div> ) : ( <div> <p>Invalid user schema</p> </div> );};
export { App };
Recap: Custom Hooks to keep React state and local storage in sync are great, though they come with one caveat of schema validation.