Build a Persist cart state across page refreshes using localStorage.
Lazy-init state from localStorage, sync to it with useEffect on change. Wrap reads/writes in try/catch (JSON parse errors, quota, private mode), debounce writes if frequent, version the schema, and sync across tabs with the storage event. Best packaged as a useLocalStorage hook.
Persisting cart state is a useState + localStorage bridge. The implementation is short; the robustness details are the interview.
The core pattern — a reusable hook
function useLocalStorage(key, initialValue) {
// lazy initializer — read localStorage once, on mount
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initialValue;
} catch {
return initialValue; // corrupt JSON, private mode, etc.
}
});
// write back whenever value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// quota exceeded / private mode — fail silently or notify
}
}, [key, value]);
return [value, setValue];
}
// usage
const [cart, setCart] = useLocalStorage("cart:v1", []);What interviewers are grading
1. Lazy initialization — pass a function to useState so localStorage is read once on mount, not on every render.
2. try/catch everywhere — JSON.parse throws on corrupt data; setItem throws on quota-exceeded or in Safari private mode; localStorage may be unavailable (SSR, disabled). Never let storage crash the app.
3. SSR safety — localStorage doesn't exist on the server. Guard with typeof window !== "undefined", or only read inside useEffect, to avoid hydration mismatches.
4. Cross-tab sync — listen for the storage event so a cart change in one tab updates the others:
useEffect(() => {
const onStorage = (e) => {
if (e.key === key && e.newValue) setValue(JSON.parse(e.newValue));
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key]);5. Schema versioning — key it cart:v1. When the cart shape changes, bump the version (or migrate) so stale data doesn't crash the new code.
6. Debounce writes — if the cart updates rapidly, debounce setItem (it's synchronous and can jank).
Senior considerations
- localStorage is not source of truth — it's a cache for guests. For logged-in users, the server owns the cart; reconcile/merge on login.
- Sensitive data — never persist tokens or PII in localStorage (XSS-readable).
- Stale prices/availability — re-validate cart contents against the server on load; a price from last week may be wrong.
The framing
"I'd build a useLocalStorage hook: lazy-init from storage on mount, write back in a useEffect on change. The robustness is the real answer — try/catch around parse and setItem for corrupt data, quota, and private mode; SSR guards to avoid hydration mismatch; a storage event listener for cross-tab sync; and a versioned key so schema changes don't break old data. And conceptually: localStorage is a guest cache, not source of truth — re-validate against the server and merge on login."
Follow-up questions
- •Why pass a function to useState instead of reading localStorage directly?
- •How do you sync the cart across multiple browser tabs?
- •What happens if localStorage is full or disabled?
- •How do you handle a localStorage schema change between app versions?
Common mistakes
- •Reading localStorage on every render instead of a lazy initializer.
- •No try/catch — corrupt JSON or quota errors crash the app.
- •Accessing localStorage during SSR, causing hydration mismatches.
- •No cross-tab sync, so tabs show different carts.
- •Persisting tokens or sensitive data in localStorage.
Performance considerations
- •localStorage access is synchronous and blocks the main thread — debounce frequent writes and keep stored payloads small. JSON.stringify/parse of a large cart on every change can jank; batch updates.
Edge cases
- •Corrupt or non-JSON value already in storage.
- •Quota exceeded (~5MB) or Safari private mode throwing on setItem.
- •Two tabs editing the cart simultaneously.
- •Old schema version persisted before an app update.
Real-world examples
- •E-commerce guest carts surviving refreshes before the user logs in.
- •Persisting UI preferences (filters, theme) via a useLocalStorage hook.