My Journey
React does not rerender when localStorage changes. To get a localStorage value updated on the UI when after it changes, I would need to force the component to rerender by updating an unused state. This felt like an unnecessary hack to get this simple concept working. How can I make localStorage be integrated more closely with React?
LocalStorage Hook
Leveraging React’s useState hook, we can create a useLocalStorage hook that solves our problems.
function useLocalStorage(key, initialState) {
const [value, setValue] = useState(localStorage.getItem(key) ?? initialState);
const updatedSetValue = useCallback(
newValue => {
if (newValue === initialState || typeof newValue === 'undefined') {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, newValue);
}
setValue(newValue ?? initialState);
},
[initialState, key]
);
return [value, updatedSetValue];
}
This will save the data to localStorage and trigger a component rerender, thereby reflecting the new localStorage value on the UI. It takes in a string key that is used as the localStorage key and a string initialState that is used to initialize useState if the key’s value does not exist. Then, it returns that value with an updated setter function that will update localStorage and state.
To use, have whatever UI element the user interacts with call the setter function.
const Component = () => {
const [value, setValue] = useLocalStorage('key', 'initial');
return (
<input
onChange={e => setValue(e.target.value)}
value={value ?? ''}
/>
);
};
It is important to use the setter function instead of directly using localStorage.setItem. React will only rerender if you use this setter function to update localStorage.
Non-string Values
The above implementation only supports string values because that’s what localStorage officially supports. However, booleans, numbers, and objects can still be supported by converting them to and from strings with JSON.stringify and JSON.parse. Using this concept, useLocalStorage can be extended to support non-string values.
function useLocalStorageNonString(key, initialState) {
const serializedInitialState = JSON.stringify(initialState);
let storageValue = initialState;
try {
storageValue = JSON.parse(localStorage.getItem(key)) ?? initialState;
} catch {
localStorage.setItem(key, serializedInitialState);
}
const [value, setValue] = useState(storageValue);
const updatedSetValue = useCallback(
newValue => {
const serializedNewValue = JSON.stringify(newValue);
if (
serializedNewValue === serializedInitialState ||
typeof newValue === 'undefined'
) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, serializedNewValue);
}
setValue(newValue ?? initialState);
},
[initialState, serializedInitialState, key]
);
return [value, updatedSetValue];
}
Before a value is saved into localStorage, it is converted into a string, and after a value is read from localStorage, it is converted into an object (or boolean or number). Because we can never guarantee the value in localStorage to be a valid JSON, we must also handle any parsing errors.
Caveats
If a user manually changes the localStorage value or some other part of the application changes the value without using the useLocalStorage updater, useLocalStorage will not update to that new value. Unfortunately, there’s no solution to this as this is a browser limitation. They do provide a storage event that you can listen to window.addEventListener('storage', ), but this handler is only fired if the storage event is triggered from another tab or window.
Final Thoughts
I really wish there was a native way for React to rerender on localStorage changes. But even without a native implementation, you can achieve the functionality with your own useLocalStorage hook.