Persistent state in React and Next.js, without a store
localStorage plus useState looks like six lines of code — until SSR, tabs, stale data and corrupt JSON show up. What production-grade persistence in React actually takes, and the 1.4 kB shortcut.
Somewhere in every React codebase there's a hook called useLocalStorage, written in ten minutes, copied from a gist, and trusted with the theme, the auth token and half the checkout. I've audited enough front-ends to state this as a law of nature.
Here's what that innocent hook is actually signing up for — and why "persistence" is a real engineering problem, not a useEffect afterthought.
The six lines everyone writes
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(
() => JSON.parse(localStorage.getItem(key) ?? 'null') ?? initial
);
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
Five production problems, in ascending order of how late they bite:
- Next.js kills it on sight.
localStoragedoesn't exist on the server — and even guarded, reading it during render makes the first client render differ from the server HTML — hello, hydration mismatch warnings. - Corrupt data throws. Storage is user-editable and outlives your deploys. One malformed entry and
JSON.parsetakes the component tree down. - Components disagree. Two components using the same key each hold their own
useStatecopy: change one, the other doesn't move. Most teams "fix" this with Context — a provider, for a string in storage. - Tabs disagree, values rot. No
storageevent handling, no expiry: last month's draft and yesterday's token are treated as fresh. - Write storms. Persisting a text input on every keystroke hammers a synchronous main-thread API.
None of this is exotic. It's the same checklist, in every app, usually rediscovered one incident at a time.
The checklist, packaged
This is the itch behind smart-state, a small open-source hook I maintain (~1.4 kB min+gzip, zero dependencies — full disclosure, it's mine):
import { useSmartState } from 'smart-state';
const [theme, setTheme] = useSmartState<'light' | 'dark'>('light', {
persist: true,
storageKey: 'theme',
syncTabs: true,
});
Drop-in useState semantics — lazy initializer, functional updater — with the whole checklist handled: SSR-safe hydration (no mismatch warnings), shared state across every component using the key without a provider (it's built on useSyncExternalStore), cross-tab sync, and error handling that degrades instead of throwing.
The production details are options, not rewrites:
// Storage is untrusted input: validate it
const [user, setUser] = useSmartState<User>(guest, {
persist: true,
storageKey: 'user',
parse: userSchema.parse, // zod, valibot, anything that throws
version: 2,
migrate: (old, from) => (from === 1 ? upgradeV1(old) : undefined),
});
// A session that expires on its own
useSmartState('', { persist: true, storageKey: 'token', ttl: 15 * 60 * 1000 });
// A draft that doesn't hammer storage on every keystroke
useSmartState('', { persist: true, storageKey: 'draft', writeDebounce: 300 });
The parse/version/migrate trio is the part I care most about: persisted data crosses your release boundaries, so it deserves the same suspicion as any external input. Very few libraries treat it that way.
"Shouldn't this just be Zustand?"
Honesty corner: if your app has genuinely complex shared state — many writers, derived data, devtools time-travel — a store like Zustand or Redux Toolkit earns its place, and persist middleware exists. But adopting a store because you need localStorage is backwards: you're taking on an architecture to get a side effect. For the theme, the filters, the draft and the cart, a persistence hook is the right size — and at 1.4 kB it costs less than the store's readme.
It's the same lesson I keep repeating in architecture reviews: name the actual requirement first. "Sharing", "persisting", "syncing" and "caching" are four different problems that all get called "state management" in meetings.
I'm an independent software architect focused on front-end work — smart-state has siblings for Vue and Nuxt that share its storage format. If your React codebase has three hand-rolled useLocalStorage hooks and a hydration warning nobody investigates, let's talk: the intro call is free.