Skip to content
LUIGI MICCA

Published

3 min read

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:

  1. Next.js kills it on sight. localStorage doesn'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.
  2. Corrupt data throws. Storage is user-editable and outlives your deploys. One malformed entry and JSON.parse takes the component tree down.
  3. Components disagree. Two components using the same key each hold their own useState copy: change one, the other doesn't move. Most teams "fix" this with Context — a provider, for a string in storage.
  4. Tabs disagree, values rot. No storage event handling, no expiry: last month's draft and yesterday's token are treated as fresh.
  5. 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.