How would you handle hydration and edge cases like invalid JSON or storage limits
Hydrate persisted state in a useEffect (not at render) to avoid SSR mismatches. Wrap JSON.parse in try/catch; treat parse failure as 'no saved state'. Catch QuotaExceededError on writes and degrade (clear oldest, warn user, switch to in-memory). Validate persisted shape with a schema (Zod) and migrate or discard on version mismatch.
Persisting state (theme, draft, cart) sounds simple. The edge cases bite:
- SSR hydration mismatch if initial render reads storage (server didn't have it).
- JSON.parse throws on corrupt/legacy data.
- localStorage quota ~5MB; writes throw
QuotaExceededError. - Storage disabled in private browsing or by user prefs.
- Schema drift across releases.
Safe load
function safeLoad<T>(key: string, schema: ZodSchema<T>, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
const parsed = JSON.parse(raw);
const result = schema.safeParse(parsed);
return result.success ? result.data : fallback;
} catch {
return fallback;
}
}Try/catch wraps both JSON.parse and storage access (storage can throw if disabled).
Safe save
function safeSave(key: string, value: unknown): boolean {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e: any) {
if (e?.name === "QuotaExceededError" || e?.code === 22) {
// Could evict older keys, downgrade to in-memory, or warn user
console.warn("Storage quota exceeded; data not persisted");
}
return false;
}
}Hydration shape for SSR + client
function useStoredState<T>(key: string, schema: ZodSchema<T>, fallback: T) {
const [state, setState] = useState<T>(fallback); // server + first client render
useEffect(() => {
setState(safeLoad(key, schema, fallback)); // load only on client
}, [key]);
useEffect(() => {
safeSave(key, state);
}, [key, state]);
return [state, setState] as const;
}The initial render uses fallback so server and client match; the effect upgrades the value on mount. Some flicker is acceptable for prefs; for theme/dark mode, set a class on <html> via inline <script> before React boots to avoid flash.
Schema migration
Version the stored shape:
const Stored = z.object({
__v: z.literal(2),
cart: z.array(CartItem),
});
function migrate(old: any): z.infer<typeof Stored> | null {
if (old?.__v === 1) {
return { __v: 2, cart: old.items?.map(transformOld) ?? [] };
}
if (old?.__v === 2) return old;
return null; // unrecognized; discard
}Zustand persist gotchas
zustand/persist covers many of these but you still need:
onRehydrateStorageto react to hydration.migratefor schema drift.versionbump on each breaking shape change.- Be careful with SSR — gate state until hydration.
Theme/dark-mode without flash
<!-- in <head>, before React boots -->
<script>
(function () {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
})();
</script>Synchronous; runs before paint; no flash. React picks up the class on mount.
Storage events for cross-tab sync
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) setState(safeLoad(KEY, schema, fallback));
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);Tabs stay in sync without polling.
When to use IndexedDB instead
- > 5MB.
- Binary data.
- Async API (doesn't block main thread).
- Use
idb-keyvalfor an API as simple as localStorage.
Interview framing
"Initial render uses the fallback so SSR and first client render match; an effect upgrades from storage afterward. safeLoad wraps storage access + JSON.parse + schema validation (Zod) in try/catch — corruption or legacy shape becomes 'no saved state'. safeSave catches QuotaExceededError and degrades (warn user, evict, switch to in-memory). Version the schema and migrate on read. For theme/dark-mode, set the class via a synchronous inline script before React boots to avoid flash. Listen to the storage event for cross-tab sync. For > 5MB go IndexedDB."
Follow-up questions
- •Walk through theme persistence without flash.
- •How would you migrate a stored shape?
- •What does QuotaExceededError look like?
Common mistakes
- •Reading localStorage during render → hydration mismatch.
- •No try/catch around JSON.parse.
- •Ignoring quota errors.
- •No schema versioning.
Performance considerations
- •Sync localStorage on the main thread; small reads are fine. Big payloads → IndexedDB (async).
Edge cases
- •Private browsing (storage disabled).
- •Corrupt JSON.
- •Schema drift across releases.
- •Cross-tab sync.
Real-world examples
- •next-themes for theme without flash, zustand/persist with migrate, dexie/idb-keyval for IndexedDB.