Build a custom React hook that syncs state with localStorage (and across tabs)
Mirror useState but read initial value from localStorage and write on change. Handle JSON errors, SSR (no window), quota exceeded, and listen to the storage event for cross-tab sync.
A persistent-state hook looks like a one-liner over useState, but the production version handles five edge cases that catch out junior implementations: SSR safety, JSON parse errors, quota exceeded, cross-tab sync, and React 18's useSyncExternalStore for tear-free reads.
Naive version:
function usePersistentState<T>(key: string, initial: T) {
const [v, setV] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initial;
});
useEffect(() => { localStorage.setItem(key, JSON.stringify(v)); }, [key, v]);
return [v, setV] as const;
}This breaks under SSR (no localStorage), parse errors (corrupt entries), full quota, and doesn't sync between tabs.
Production version:
function usePersistentState<T>(key: string, initial: T) {
const read = useCallback((): T => {
if (typeof window === "undefined") return initial; // SSR
try {
const raw = window.localStorage.getItem(key);
return raw === null ? initial : (JSON.parse(raw) as T);
} catch {
return initial;
}
}, [key, initial]);
const [value, setValue] = useState<T>(read);
// Persist on change
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// QuotaExceededError or serialization error — drop silently or surface a toast
}
}, [key, value]);
// Cross-tab sync
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === key && e.storageArea === window.localStorage) {
try {
setValue(e.newValue === null ? initial : (JSON.parse(e.newValue) as T));
} catch {
/* ignore corrupt write from another tab */
}
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, initial]);
return [value, setValue] as const;
}Key decisions explained.
- Lazy initial state. Pass
read(function) touseStateso localStorage reads only happen on mount, not every render. - SSR. Guard
typeof windowso server render returns the fallback. Hydration then re-runs the read on client. - JSON errors. Wrap parse in try/catch; corrupt entries shouldn't crash the app.
- Quota.
setItemcan throwQuotaExceededError. Decide: silently drop (good for caches), or notify (good for user data). - Cross-tab. The
storageevent fires in other tabs when localStorage changes — perfect for "logout in tab A logs out tab B" or "preference change syncs."
React 18 upgrade: useSyncExternalStore. For tear-free concurrent rendering, the canonical implementation uses useSyncExternalStore so reads are consistent across a render. Most apps don't need this, but it's the spec-correct version.
function usePersistentState<T>(key: string, initial: T) {
const subscribe = useCallback((cb: () => void) => {
const handler = (e: StorageEvent) => { if (e.key === key) cb(); };
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}, [key]);
const getSnapshot = () => window.localStorage.getItem(key);
const getServerSnapshot = () => null;
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const value = useMemo<T>(() => { try { return raw ? JSON.parse(raw) : initial; } catch { return initial; } }, [raw, initial]);
const setValue = useCallback((next: T | ((prev: T) => T)) => {
const updated = typeof next === "function" ? (next as any)(value) : next;
window.localStorage.setItem(key, JSON.stringify(updated));
// Manually trigger same-tab listeners (storage event doesn't fire in writing tab)
window.dispatchEvent(new StorageEvent("storage", { key, newValue: JSON.stringify(updated) }));
}, [key, value]);
return [value, setValue] as const;
}Same-tab caveat. The storage event does NOT fire in the tab that did the write. If multiple components in the same tab use the same key, you need a custom event (as above) or a shared in-memory store.
Code
Follow-up questions
- •Why does the storage event not fire in the writing tab?
- •How would you adapt this for sessionStorage?
- •How does useSyncExternalStore prevent tearing?
- •How would you support a TTL / expiry per key?
Common mistakes
- •Reading localStorage at render time (not lazy) — perf hit and SSR crash.
- •No try/catch on JSON.parse — one corrupt key crashes the app.
- •Forgetting cross-tab sync — preferences diverge between tabs.
- •Letting QuotaExceededError bubble — kills the app on full storage.
Performance considerations
- •JSON serialize on every change — fine for small data, costly for large objects. Throttle if needed.
- •Many components reading the same key independently each parse JSON; share via context if hot.
Edge cases
- •Quota full → setItem throws; fall back to in-memory or evict.
- •Private mode in some browsers gives 0 quota.
- •Key removed in another tab → e.newValue is null; restore initial.
Real-world examples
- •Theme toggles, sidebar collapsed state, draft autosaves, recent-search history, opt-in flags.