A theme choice, a collapsed sidebar or a draft message should survive a reload. This Hook keeps useState's API — a value and a setter that accepts an updater function — and mirrors every change into localStorage.
import { useState, useCallback } from 'react';
export function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key);
return raw === null ? initial : JSON.parse(raw);
} catch { return initial; } // private mode, disabled storage, bad JSON
});
const set = useCallback(next => setValue(prev => {
const resolved = next instanceof Function ? next(prev) : next;
try { localStorage.setItem(key, JSON.stringify(resolved)); } catch {}
return resolved;
}), [key]);
return [value, set];
}Call it exactly like useState: const [theme, setTheme] = useLocalStorage('theme', 'light');. The write happens inside the setter, not in an Effect, which avoids a pointless write on every mount.
Three details are easy to get wrong. localStorage stores strings only, so anything else needs the JSON round trip — and a value written by an older version of your app may no longer parse, which is what the first catch is for. Access can throw outright: Safari 10 's private mode and a full quota both reject writes, and localStorage does not exist during server rendering, so a Next.js 10,514 page must read it in an Effect or guard on typeof window. Finally, the Hook does not notice changes made in another tab; if that matters, listen for the storage event or wire the key through useSyncExternalStore (useSyncExternalStore).