useLocalStorage Hook
Sync state to localStorage with JSON, SSR fallback, cross-tab storage events, and parse errors that should not crash the tree.
- react
- hooks
- storage
- interview
Theme, sidebar width, a draft title: teams stash them in localStorage and then watch the tree crash on JSON.parse of garbage, hydrate with the wrong theme, or ignore the other tab. useLocalStorage is a custom hook that treats Web Storage as an external store, not as a side effect you sprinkle in onClick.
The storage API is synchronous, origin-scoped, and strings only. Your hook owns the JSON boundary, the missing-window path for SSR, and the storage event that fires in other documents of the same origin, not in the tab that called setItem.
A parse error is a data problem. It is not a reason to unmount the app. Catch it, fall back, move on.
- Lazy-initialize state by reading once. Do not
getItemduring every render.JSON.stringify/JSON.parseintry/catch. Quota, private mode, and corrupt strings are normal.- The
storageevent syncs other tabs. Same-tab writes do not fire it; your setter already updated React state.- SSR:
windowis missing. Reading storage on the client’s first render can mismatch server HTML. Pick a default or defer the read.- Cleanup the listener. Follow Rules of Hooks: always call the hook, no-op when
windowis absent.
Read once, write on change, listen for other tabs
import { useEffect, useState } from 'react';
function readStorage(key, fallback) {
if (typeof window === 'undefined') return fallback;
try {
const raw = window.localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw);
} catch {
return fallback;
}
}
function useLocalStorage(key, initialValue) {
const fallback =
typeof initialValue === 'function' ? initialValue : () => initialValue;
const [value, setValue] = useState(() => readStorage(key, fallback()));
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// QuotaExceededError, SecurityError in some private modes
}
}, [key, value]);
useEffect(() => {
function onStorage(event) {
if (event.storageArea !== window.localStorage) return;
if (event.key !== key) return;
if (event.newValue == null) {
setValue(fallback());
return;
}
try {
setValue(JSON.parse(event.newValue));
} catch {
setValue(fallback());
}
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [key]);
return [value, setValue];
}
Three jobs, three mechanisms:
| Job | Mechanism |
|---|---|
| Initial read | useState lazy initializer (() => ...) so getItem runs once per mount |
| Persist | Effect writes when key or value changes |
| Cross-tab | storage event; cleanup removeEventListener |
The lazy initializer is the same pattern as expensive useState setup in useState Patterns. If you write useState(readStorage(key, fallback())), you still call getItem on every render even though React throws the result away after the first time.
JSON is not your object model
localStorage stores strings. JSON.stringify drops undefined keys, turns NaN / Infinity into null, converts Date to an ISO string, and throws on cycles. Map, Set, and functions do not survive. That is the same surface as JSON parse and stringify pitfalls.
JSON.stringify({ t: undefined, d: new Date(), n: NaN });
// '{"d":"2026-11-08T00:00:00.000Z","n":null}'
JSON.stringify(undefined) returns the value undefined, not a string. setItem then stores the characters "undefined", and JSON.parse('undefined') throws SyntaxError. Do not persist undefined; use null or skip the write.
Corrupt data (manual DevTools edit, a previous schema) should hit catch and return initialValue. Do not let the hook throw during render.
SSR and hydration
Effects do not run on the server. If the initializer uses typeof window === 'undefined' and returns initialValue, the server HTML uses the default.
On the client, that same initializer runs again for hydration. If you read localStorage there and the stored theme is 'dark' while the server printed 'light', React reports a hydration mismatch and may discard the server DOM.
Pick one:
- Defer the read. Initialize with
initialValuealways. In an effect,setValue(readStorage(key, initialValue)). First paint matches the server; the stored value applies after mount (one frame of wrong theme). - Client-only tree. Only call the hook under a component that does not SSR, or accept the mismatch for a non-visual cache.
useSyncExternalStore.getServerSnapshotreturns the default;getSnapshotreads storage. This is the concurrent-safe subscribe API (useSyncExternalStore Overview).
Interviews want option 1 or 3 named. “I called localStorage in render and it worked in CRA” is not an SSR answer.
Same tab vs other tab
The HTML spec’s storage event fires on other Window objects that share the origin, not on the one that wrote. Your setValue already updated this tab. Listening and re-parsing in the same tab is wasted work and can fight in-memory state (you just set a Date object; the event would give you a string).
Same-tab sync across hook instances that share a key needs a custom event or a tiny module store. Do not pretend storage does that.
When key is removed (removeItem or clear in another tab), event.newValue is null and event.key may be null on clear. Handle both or you leave a zombie value on screen.
Footguns
initialValue as a new object every render. useState(() => readStorage(key, initialValue)) captures the first object. Later fallback() in the storage listener, if closed over a changing initialValue, is a stale default. Prefer a lazy function () => ({ theme: 'light' }) so each fallback call builds a fresh default.
Writing in the setter and in the effect. Double setItem is harmless. Writing only in a wrapped setter and skipping the effect means a setValue from the storage listener will not echo back (good) but a setValue from React will. Pick one write path; the effect-on-value path is the obvious one.
Secrets. XSS reads localStorage. Session tokens belong in HttpOnly cookies. See Browser Storage Comparison.
Quota. setItem throws QuotaExceededError (often ~5 MB per origin). Swallowing in the effect keeps the UI alive; the next reload loses the write. Surface that if the data matters.
Key changes. If key is a prop and it changes, the state still holds the old key’s value until you reset. Either include key in a reset effect (setValue(readStorage(key, fallback()))) or treat key as identity (remount with key={storageKey} on the component).
Interview out-loud answer
I lazy-read
localStorageinsideuseState, wrapgetItem/JSON.parsein try/catch, and fall back to the initial value on SSR or corrupt JSON. An effect writesJSON.stringifyon change and catches quota errors. Another effect listens forstorageto sync other tabs, and removes the listener on cleanup. I would not expect that event in the same tab. For SSR I either delay the read until after mount or useuseSyncExternalStoreso the first client render matches the server.
Further reading
- MDN:
Window.localStorage - MDN:
Window: storageevent - HTML spec: Web Storage
- react.dev:
useState(lazy initial state)
Related on this site
- localStorage and sessionStorage: strings, quota, tab scope
- JSON parse and stringify pitfalls: what JSON drops
- Implement JSON.stringify: interview-level JSON rules
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- Hydration Mismatches
- try/catch/finally
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.