React useLocalStorage Hook: SSR-Safe Persistent State (2026)
Why Not Just useState + useEffect?
Here is the version that ends up in most codebases, and it's buggier than it looks:
function usePersistedState(key: string, defaultValue: string) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key); // π see below
return stored !== null ? JSON.parse(stored) : defaultValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
- It breaks under SSR. On the server there is no
localStorage, so the initializer throws. Guard it withtypeof windowand you trade the crash for a hydration mismatch: the server rendered the default, the client immediately renders the stored value, and React warns - or worse, silently patches the wrong DOM. - It throws on bad data.
JSON.parseover a value that was hand-edited, half-written, or saved by an older version of your app takes the whole component down with it. - It ignores other tabs. The user changes the setting in tab A; tab B keeps showing - and re-saving - the stale value until a full reload.
- It ignores other components. Two components calling
usePersistedState('theme', β¦)each hold their ownuseState. One updates, the other doesn't re-render. Same key, two truths.
Each bug is fixable by hand, and the fixes add up to exactly what a good hook already is.
useLocalStorage - useState That Survives Reloads
The API is deliberately shaped like useState: a tuple of value and setter, with the default as the second argument.
import { useLocalStorage } from '@reactuses/core';
function Settings() {
const [layout, setLayout] = useLocalStorage('dashboard-layout', 'grid');
return (
<select value={layout ?? 'grid'} onChange={(e) => setLayout(e.target.value)}>
<option value="grid">Grid</option>
<option value="list">List</option>
</select>
);
}
The signature is useLocalStorage(key, defaultValue, options?):
const [value, setValue] = useLocalStorage<T>(key, defaultValue, options);
// value: T | null
// setValue: Dispatch<SetStateAction<T | null>>
On first visit value is the default; after any setValue, the value is written to localStorage and comes back on the next reload. Functional updates work exactly like useState's: setValue(prev => β¦) receives the current stored value. The only visible difference from useState is the type: value is T | null, because a persisted key can also be removed - more on that below.
Objects, Maps, Sets, Dates - Serialization Is Automatic
localStorage only stores strings; the hook picks the right serializer for you by looking at the type of the default value. Pass an object and it round-trips through JSON.stringify / JSON.parse; pass a number and you get a number back, not "42":
const [filters, setFilters] = useLocalStorage('filters', {
status: 'open',
assignee: null as string | null,
});
const [fontSize, setFontSize] = useLocalStorage('font-size', 16);
const [seen, setSeen] = useLocalStorage('seen-ids', new Set<string>());
const [lastVisit, setLastVisit] = useLocalStorage('last-visit', new Date());
That last pair is the part hand-rolled versions never handle: Map, Set, and Date defaults get dedicated serializers (Set β JSON array, Date β ISO string, and back), so seen is a real Set with .has() after a reload - not a stringified husk.
When the built-ins don't fit - say the value needs to stay compatible with a format some other system wrote - pass your own:
const [config, setConfig] = useLocalStorage('legacy-config', defaultConfig, {
serializer: {
read: (raw) => parseLegacyFormat(raw),
write: (value) => toLegacyFormat(value),
},
});
Removing the Key: setValue(null)
Persisted state has one operation useState doesn't: forget this. Setting the value to null removes the key from localStorage entirely:
const [token, setToken] = useLocalStorage<string>('auth-token', null);
// login
setToken(response.token);
// logout - key is deleted from localStorage, value becomes null
setToken(null);
This is why the value's type is T | null. A removed key stays null for the rest of the session - it does not snap back to the default - which is what you want: "logged out" and "never logged in with a default" are different states, and the hook doesn't blur them.
SSR and Hydration, Actually Safe
useLocalStorage is built on useSyncExternalStore - React's own primitive for subscribing to external data - with a server snapshot that returns the default value. That one design choice buys three things:
- No crash on the server. The hook never touches
windoworlocalStorageduring server rendering. Notypeof windowguard in your code. - No hydration mismatch. The client's first render deliberately matches the server HTML (the default), then React re-renders with the stored value through the proper
useSyncExternalStorepath - no warning, no patched-over DOM. - Concurrent-safe reads. Because storage is treated as an external store, React 18+ features like transitions never see a torn value.
The one thing no localStorage hook can eliminate is the brief flash of the default before the stored value appears - the server genuinely doesn't know what's in the browser's storage. For values where that flash hurts (theme is the classic), the fix lives outside React, in a blocking inline script; the trade-offs are covered in SSR-Safe React Hooks.
And when storage itself is unavailable - some privacy modes, or storage access that throws - the hook degrades to a plain in-memory state holder and reports the failure through onError instead of crashing:
const [draft, setDraft] = useLocalStorage('draft', '', {
onError: (e) => trackWarning('storage unavailable', e),
// default: console.error
});
The same onError catches corrupted data (the JSON.parse bug from the naive version - the hook returns the default instead of throwing) and quota-exceeded writes.
In Sync Across Tabs - and Across Components
Change the value in one tab and every other tab updates instantly, because the hook listens to the browser's native storage event:
// Tab A and Tab B both render this - flip it in one, both update.
const [theme, setTheme] = useLocalStorage('theme', 'light');
Cross-tab sync is on by default; opt out with listenToStorageChanges: false if a tab should keep its own view until reload.
The subtler half is same-tab sync. The native storage event never fires in the tab that made the change, so in a hand-rolled hook, a theme toggle in the header updates the header - and the sidebar reading the same key keeps its stale copy. useLocalStorage re-broadcasts every write internally, so every component on the same key re-renders together, always. Two components, one key, one truth - the drift bug from the naive version simply doesn't exist.
(If you're syncing more than persisted state across tabs, Cross-Tab State in React covers the full toolbox.)
The Storage Family
useLocalStorage has siblings; pick by where the value should live and how long:
| Hook | Persists in⦠| Survives | Cross-tab |
|---|---|---|---|
useLocalStorage |
localStorage |
reloads + browser restarts | β synced |
useSessionStorage |
sessionStorage |
reloads, per tab | β per-tab by design |
useCookie |
cookies | per cookie options; sent to the server | β |
useBroadcastChannel |
nothing (messaging only) | - | β live messaging |
useSessionStorage shares the exact same API and serialization - swap the import and the value becomes per-tab. useCookie is the one to reach for when the server needs the value on the first request (which is also the real fix for the theme-flash problem). useBroadcastChannel isn't storage at all, but it's the right tool when tabs need to talk rather than persist.
Takeaways
- The hand-rolled
useState+useEffect+localStoragecombo ships four bugs: SSR crash or hydration mismatch,JSON.parsecrashes on bad data, no cross-tab sync, and drift between components sharing a key. useLocalStorage(key, defaultValue)is a drop-inuseStatethat persists - same tuple, same functional updates, typedT | null.- Serialization is automatic and driven by the default's type - objects, arrays, numbers, booleans, and even
Map,Set, andDateround-trip correctly. Custom serializer when you need a specific format. setValue(null)removes the key - "cleared" is a real state, distinct from the default.- Built on
useSyncExternalStore: SSR-safe with no guards, hydration-mismatch-free, and it degrades to in-memory state (withonError) when storage is blocked. - Sync is total: native
storageevents across tabs (toggle withlistenToStorageChanges), internal re-broadcast across components in the same tab - always on. - Same API, different lifetime:
useSessionStoragefor per-tab,useCookiewhen the server needs it too.
Grab it from @reactuses/core and let refresh stop meaning reset.
Comments
No comments yet. Start the discussion.