localStorage Is Not State: Persistence, Hydration, and Tabs That Fight Each Other

Jul 13, 2026
11 min read
localStorage Is Not State: Persistence, Hydration, and Tabs That Fight Each Other

At the end of the last article I said I wanted to start this run with the deceptively simple persistence question: what belongs in localStorage, what belongs in state, and what breaks when you pretend they're the same thing. It sounds like a beginner question. It isn't. I've seen senior codebases get this wrong in ways that produce flickering UIs, tabs that overwrite each other, and a crash the first time someone ships a new version of the app.

The root mistake is almost always the same, and it's a framing problem. localStorage looks like a global variable that happens to survive a refresh. localStorage.getItem('theme') reads like window.theme. So people start treating it as a place to keep state — reading from it in render, writing to it inline, scattering getItem/setItem across components. That framing is the bug. Everything downstream goes wrong because of it.

Three concerns people mash into one

When someone says "I'll just store it in localStorage," they're usually blending three separate things that have nothing to do with each other:

Source of truth — where the current value lives right now, in memory, reactive, driving the UI. That's your state (a store atom, context, useState).

Persistence — how that value survives a reload. That's storage: localStorage, IndexedDB, a cookie.

Cross-tab sync — how two open tabs agree on the value. That's a messaging problem.

localStorage can only honestly do the middle one. It is not reactive — writing to it doesn't re-render anything. It is synchronous and string-only. And it's shared across tabs in a way that will surprise you. The moment you ask it to be your source of truth, you've handed the hardest job to the tool least suited for it.

Storage is a mirror of your state, not the state itself. The state lives in memory and drives the UI; storage just remembers what it was so you can restore it later.

Once that sentence is the rule, the architecture falls out of it. State is the thing components read and write. Storage is a side effect: you hydrate from it once at startup, and you persist to it when state changes. Components never touch storage directly.

The render-time read that breaks SSR

Here's the most common concrete failure, and it's worth seeing clearly. Someone initializes state straight from storage:

// looks fine, quietly broken function useTheme() { const [theme, setTheme] = useState(localStorage.getItem('theme') ?? 'light'); // ... }

On a purely client-rendered app this mostly works. On anything server-rendered — Next.js, which is what this whole series builds on — it detonates two ways. First, localStorage doesn't exist on the server, so the render throws. Second, even if you guard it, the server has no idea what's in the user's storage, so it renders light, the client hydrates with dark, and React screams about a hydration mismatch — or worse, silently flips the UI after paint. That flash of the wrong theme is the visual signature of "someone read storage during render."

The fix follows directly from the mirror rule. Render from state with a stable default, then hydrate from storage in an effect, after the first paint:

function useTheme() { const [theme, setThemeState] = useState<'light' | 'dark'>('light'); // hydrate once, on the client only useEffect(() => { const stored = localStorage.getItem('theme'); if (stored === 'light' || stored === 'dark') setThemeState(stored); }, []); const setTheme = useCallback((next: 'light' | 'dark') => { setThemeState(next); localStorage.setItem('theme', next); // persist as a side effect }, []); return [theme, setTheme] as const; }

Server and client both start at light, so hydration matches. The stored value takes over a tick later. If the theme flash bothers you, that's a real concern — but you solve it with a tiny inline script in the document head that sets a class before React boots, not by reading storage during React's render. The two problems stay separate.

Persist the store, not a hundred components

Doing this hook-by-hook gets old fast, and it scatters setItem calls everywhere — which is exactly the kind of smeared side effect that makes a codebase hard to reason about. Since this series uses Reatom for state, persistence belongs at the store layer, as one declarative concern, not a hundred manual writes.

import { atom } from '@reatom/core'; import { withLocalStorage } from '@reatom/persist-web-storage'; // the atom is the source of truth; localStorage is a declared mirror of it export const themeAtom = atom<'light' | 'dark'>('light', 'themeAtom').pipe( withLocalStorage('theme'), );

Now the atom is where the value lives and where the UI reads it. The persistence adapter mirrors every change to localStorage and rehydrates on load — including, importantly, listening for changes from other tabs. You wrote zero getItem/setItem calls. The concern lives in one place you can see, test, and change. This is the same principle from the composition and DI article: don't let components reach out and grab infrastructure; hand it to them, or push it to a layer that owns it.

Two tabs, one storage, a fight

The surprise that catches people: localStorage is shared across every tab on the same origin. Open your app in two tabs, change the theme in one, and the other tab's in-memory state has no idea. Its localStorage updated underneath it, but React state didn't move. The two tabs now disagree, and whichever one writes next wins, clobbering the other.

The browser gives you exactly one tool for this — the storage event, which fires in other tabs (never the one that made the change):

useEffect(() => { const onStorage = (e: StorageEvent) => { if (e.key === 'theme' && (e.newValue === 'light' || e.newValue === 'dark')) { setThemeState(e.newValue); } }; window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }, []);

A good persistence adapter (like Reatom's above) already does this for you, which is another reason to centralize it. But you should know the event exists, because the "tabs fight each other" bug is invisible in development — you almost never have two tabs open while coding — and then very visible in production. If tab sync actually matters for your feature, BroadcastChannel is the more deliberate tool; storage is the free one you get for persisting at all.

Versioning: the crash that ships with v2

Here's the failure that only shows up after you succeed. You persist a settings object. Months later you change its shape — rename a field, split one setting into two. You deploy. Every returning user's browser hydrates the old shape into code that expects the new shape, and something reads settings.notifications.email on an object where notifications is now a string. Crash. On load. For existing users only — which is why your testing missed it.

Persisted data is data you shipped in the past, running against code you shipped today. It needs a version and a migration path, exactly like a database:

type StoredSettings = { version: number; theme: 'light' | 'dark'; density: 'cozy' | 'compact' }; function loadSettings(): StoredSettings { const raw = localStorage.getItem('settings'); const parsed = raw ? JSON.parse(raw) : null; // no data, or an old shape we no longer trust → start clean if (!parsed || parsed.version !== 2) { return { version: 2, theme: 'light', density: 'cozy' }; } return parsed; }

Reject-and-reset is the cheapest correct answer for preferences — losing someone's theme choice is not a tragedy. For anything you can't throw away, write real migrations keyed on version. Either way, the non-negotiable part is: never trust the shape of persisted data. It came from a version of your app you don't control anymore.

So what actually goes in there

Pulling it together, the fit test is simple: store things the client owns and the client alone can restore. Theme and UI preferences. Sidebar collapsed or expanded. A draft of a form the user hasn't submitted. The last route they were on. Small, client-owned, non-sensitive, and fine to lose in the worst case.

What doesn't belong: anything sensitive (localStorage is readable by any script on the page — a token there is a token exposed to every XSS), anything large (multi-megabyte blobs want IndexedDB, which is async and built for size), and — the big one — server data. The list of customers, the current user's profile, the order you just fetched: that data is not yours. It has an owner on the backend, and your copy is a cache, not a persisted preference. Stuffing it into localStorage means you now have two stale copies fighting instead of one.

And that's the seam into the next article. Everything here was about state the client owns — you're the source of truth, storage just remembers it. Server state flips that on its head: the source of truth lives somewhere else, the same entity shows up in five different responses, and "persistence" stops being the problem while "which copy is correct" becomes it. That's data normalization and cache invalidation, and it's where a lot of otherwise-clean apps quietly turn to mush.

If you've got a persistence bug you can't quite pin down — a flash on load, a tab that resets another, a crash that only hits returning users — tell me the symptom. Nine times out of ten it traces straight back to treating storage as state, and I'd like to hear the tenth.

Found this helpful?

Subscribe on Telegram for new posts, or reach out to discuss your project.