How would you create a React hook that syncs component state with localStorage or sessionStorage
`useStorage(key, initial, storage)`: lazy init from storage (parse JSON, fallback to initial); useState pair; useEffect writes to storage when value changes; listen to `storage` event to sync across tabs; handle SSR (no `window` on server) by skipping storage on first render; handle parse errors and quota.
Useful, classic custom hook. Looks small; has subtle correctness corners — SSR, cross-tab sync, parse errors, quota.
The hook
import { useState, useEffect, useCallback } from "react";
export function useStorage(key, initialValue, storageType = "local") {
const storage = typeof window !== "undefined"
? (storageType === "local" ? localStorage : sessionStorage)
: null;
// Lazy init: read once on mount
const [value, setValue] = useState(() => {
if (!storage) return initialValue; // SSR
try {
const raw = storage.getItem(key);
return raw != null ? JSON.parse(raw) : initialValue;
} catch {
return initialValue;
}
});
// Write to storage when value changes
useEffect(() => {
if (!storage) return;
try {
if (value === undefined) storage.removeItem(key);
else storage.setItem(key, JSON.stringify(value));
} catch (err) {
console.warn(`useStorage: failed to set ${key}`, err); // quota / privacy mode
}
}, [key, value, storage]);
// Cross-tab sync
useEffect(() => {
if (!storage || storageType !== "local") return;
const onStorage = (e) => {
if (e.key === key && e.storageArea === storage) {
try {
setValue(e.newValue != null ? JSON.parse(e.newValue) : initialValue);
} catch {/* ignore */}
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, storage, storageType]);
const remove = useCallback(() => setValue(undefined), []);
return [value, setValue, remove];
}Usage
const [theme, setTheme] = useStorage("theme", "light");
const [draft, setDraft] = useStorage("draft", { title: "" }, "session");Why each piece
Lazy init
The function form of useState runs once. Reading from localStorage synchronously is fine for small values; lazy init avoids running it on every render.
SSR-safe
On the server, window is undefined → storage is null. We return initialValue without reading or writing. Hydration mismatch is the risk here — see the section below.
Effect-driven write
Write in a useEffect so it doesn't run during render. Catches sync (quota exceeded, private mode in older browsers).
Cross-tab sync
The storage event fires in other tabs when a tab sets/removes a value (not in the writing tab itself). Listen to keep tabs in sync. sessionStorage is per-tab so this doesn't apply.
SSR / hydration concern
The first client render returns initialValue (because the server can't read storage), then the effect (or a subsequent render) updates to the stored value — causing a flash.
Mitigations:
useSyncExternalStorefor hydration-correct external state (React 18).- Render a placeholder until mounted:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null; // or skeleton- For theme specifically, set the class on
<html>server-side (cookie) or in a small inline<script>before React paints.
useSyncExternalStore version (hydration-correct)
import { useSyncExternalStore } from "react";
export function useStorageSync(key, initialValue) {
const subscribe = (callback) => {
window.addEventListener("storage", callback);
return () => window.removeEventListener("storage", callback);
};
const getSnapshot = () => {
const raw = localStorage.getItem(key);
return raw != null ? raw : JSON.stringify(initialValue);
};
const getServerSnapshot = () => JSON.stringify(initialValue);
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const value = JSON.parse(stored);
const setValue = (next) => {
const v = typeof next === "function" ? next(value) : next;
localStorage.setItem(key, JSON.stringify(v));
// dispatch a synthetic storage event for same-tab listeners (built-in storage event doesn't fire in same tab)
window.dispatchEvent(new StorageEvent("storage", { key, newValue: JSON.stringify(v) }));
};
return [value, setValue];
}Edge cases
- JSON parse failure — fall back to initial.
- Quota exceeded — warn, don't crash.
storageevent doesn't fire in same tab — manually dispatch if multiple parts of the same tab use the hook.- Functional setter with
useStateworks for free (setValue((v) => ...)). - Key changes — the hook re-reads on key change; intended behavior.
- Schema migrations — when shapes evolve, version your keys (
theme.v2).
Don't use for
- Sensitive data — XSS-readable.
- Large data — IndexedDB instead.
- Cross-domain — storage is origin-scoped.
Interview framing
"useStorage(key, initial) returns [value, setValue]. Lazy-init from storage on mount (JSON parse, fallback on error); write to storage in a useEffect on value change. Listen to the storage event to sync across tabs. SSR-safe by skipping storage when window is undefined and using initialValue — but that causes a hydration flash; for hydration-correct behavior use useSyncExternalStore with a server snapshot or render a placeholder until mounted. Handle parse failures, quota errors (especially Safari private mode), and dispatch a synthetic storage event for same-tab subscribers if you need them. Don't store secrets — XSS-readable."
Follow-up questions
- •Why does the storage event not fire in the same tab?
- •How do you avoid the hydration flash for theme?
- •What does useSyncExternalStore give you here?
- •How do you handle storage schema changes over time?
Common mistakes
- •Reading from storage on every render (not lazy init).
- •Writing in render instead of effect.
- •Forgetting cross-tab storage event listener.
- •Crashing on quota / parse errors.
- •Hydration mismatch from server returning initial.
Performance considerations
- •Storage reads are sync (small ok, large blocks). Debounce writes for very hot state.
Edge cases
- •Safari private mode quota = 0.
- •Storage event not firing in same tab.
- •Same-key writes in fast succession from different components.
Real-world examples
- •Theme preference, last-used filter, draft form data.
- •useHooks-ts useLocalStorage, usehooks-ts library.