Persisting UI State in localStorage Without a Hydration Mismatch
Prerendered HTML cannot know what one browser saved. An empty server snapshot, the getSnapshot cache that stops an infinite render loop, and why theme is the opposite problem.
A hydration mismatch from localStorage is not a bug in your storage code — it is a claim that prerendered HTML can know what one browser saved. It cannot. The fix is to read storage through useSyncExternalStore with a server snapshot that is always empty, so React renders the empty state first and swaps after hydration, by design.
The pattern is four lines longer than the useEffect version everybody writes, and it removes an entire category of bug rather than suppressing the warning.
Why the usual fix is the wrong shape
The two things most people reach for:
// Reaches for state, gets a cascading re-render and a flash
const [saved, setSaved] = useState<Entry[]>([]);
useEffect(() => {
setSaved(JSON.parse(localStorage.getItem(KEY) ?? "[]"));
}, []);
// Silences the warning without fixing anything
<div suppressHydrationWarning>{saved.length}</div>
The first works, mostly, at a cost: every mount schedules an extra render pass after paint, nothing keeps two components in sync, and a second tab changing the value is invisible. The second tells React to stop checking a mismatch you have not resolved — which is right for a timestamp and wrong for anything a user can act on.
localStorage is not React state that happens to be persisted. It is an external store that React is allowed to read, and React ships a hook whose entire job is subscribing to one safely.
The pattern
"use client";
import { useSyncExternalStore } from "react";
export function useWishlist() {
const entries = useSyncExternalStore(
subscribeWishlist, // subscribe
readWishlist, // client snapshot
getServerWishlistSnapshot, // server snapshot — always empty
);
// False during SSR and the hydration render, true afterwards.
const hydrated = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
return { entries, hydrated };
}
Three arguments, three jobs. The server snapshot is what the prerendered HTML contains — empty, because a static file cannot know what your browser saved. The client snapshot is the real value, taken after hydration. The subscription is how every other component finds out when it changes.
That second useSyncExternalStore is the idiom for "is this the client yet". It exists because a count badge that renders 0 for one frame and then 3 is worse than one that renders nothing and then 3. Callers hold the badge back until hydrated is true.
All instances stay in sync for free. Hearting a product on a card updates the header count and any open side-sheet with no prop drilling and no context provider, because they are all subscribed to the same store.
The trap that will cost you an afternoon
useSyncExternalStore calls the snapshot getter on every render and bails out only when the result is referentially equal to the previous one. Parse fresh each call and you return a new array every time:
// Infinite render loop. Looks completely reasonable.
export function readWishlist() {
return JSON.parse(localStorage.getItem(KEY) ?? "[]");
}
New array → not referentially equal → re-render → new array. React eventually throws "The result of getSnapshot should be cached to avoid an infinite loop", which is a much better error than the silent version, but the fix is not obvious from it.
Cache the parse, keyed on the raw string:
let snapshotCache: { raw: string | null; value: Entry[] } = {
raw: null,
value: EMPTY,
};
export function readWishlist(): Entry[] {
if (typeof window === "undefined") return EMPTY;
const raw = readRaw();
if (raw === snapshotCache.raw) return snapshotCache.value;
snapshotCache = { raw, value: parseWishlist(raw) };
return snapshotCache.value;
}
EMPTY is a module-level constant, not a fresh []. Same reason.
Same-tab changes need their own event
This one is documented and still surprises people: the storage event only fires in other tabs. The tab that wrote the value gets nothing, so a same-tab toggle would update storage and leave the header badge stale.
Broadcast your own event on write, and subscribe to both:
export const WISHLIST_CHANGED_EVENT = "asoc:wishlist:changed";
function persist(entries: Entry[]): Entry[] {
try {
window.localStorage.setItem(KEY, JSON.stringify(entries.slice(0, MAX)));
} catch {
// Quota exceeded / storage disabled — return the in-memory result so the
// current page stays consistent for this session.
}
window.dispatchEvent(new Event(WISHLIST_CHANGED_EVENT));
return entries;
}
export function subscribeWishlist(listener: () => void): () => void {
if (typeof window === "undefined") return () => {};
const onStorage = (e: StorageEvent) => {
if (e.key === null || e.key === KEY) listener();
};
window.addEventListener(WISHLIST_CHANGED_EVENT, listener);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(WISHLIST_CHANGED_EVENT, listener);
window.removeEventListener("storage", onStorage);
};
}
The e.key === null case is not padding — that is what a clear() looks like, and skipping it leaves a stale list after the user wipes site data in another tab.
Note also that every raw storage access is wrapped. Safari private mode and "storage disabled" both throw on access rather than returning null, and an unguarded localStorage.getItem there takes down the render.
Validate on read, because this data reaches the DOM
localStorage is attacker-controlled in exactly one scenario — the user editing their own browser storage — so the direct impact of bad data is self-inflicted and nil. Validate anyway, because the values flow into an href and an <img src>:
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function isLocalImagePath(value: string): boolean {
// `//evil.example` is rejected too — browsers read a leading `//`
// as protocol-relative, i.e. a remote host.
return value.length > 1 && value.length <= 300
&& value.startsWith("/") && !value.startsWith("//");
}
export function parseWishlist(raw: string | null): Entry[] {
if (!raw) return [];
let parsed: unknown;
try { parsed = JSON.parse(raw); } catch { return []; }
if (!Array.isArray(parsed)) return [];
const seen = new Set<string>();
const entries: Entry[] = [];
for (const item of parsed) {
if (!isValidEntry(item) || seen.has(item.slug)) continue;
seen.add(item.slug);
// Rebuild rather than pushing `item` — drops any extra keys a future
// version (or a tamperer) added, so only known fields survive.
entries.push({ slug: item.slug, name: item.name, image: item.image });
if (entries.length >= MAX) break;
}
return entries;
}
Three properties, each earning its lines. The slug shape means a /templates/<slug> link can never become javascript:… or an off-site URL. The root-relative image check means a tampered entry cannot point a thumbnail at a third-party host and leak a page view. And the function never throws: corrupt JSON, a non-array payload or one bad entry degrades to "fewer saved items", never to a crashed render.
Store what you paint, not a foreign key
The instinct is to store bare slugs and resolve them at render. Do not, if the resolution costs a bundle.
Our saved side-sheet renders on every page. Resolving a slug to a name and thumbnail would mean importing the catalog — a ~7,000-line data module — into the global client bundle, on pages that have nothing to do with saved items. So each entry carries the three fields the UI actually paints:
export interface WishlistEntry {
slug: string;
name: string;
/** Thumbnail path under `public/` — always root-relative. */
image: string;
}
The catalog stays the source of truth for everything that matters — price, editions, availability. A renamed product shows a stale label in the saved list until it is re-saved, which is a real cost, honestly small, and a good trade against shipping a data module to every route.
This is the same lesson as the one that cost us most: a module-scope import of the Supabase auth client in the site header put 68 KiB gzipped, 255 KiB parsed into the initial bundle of pages with no account UI. What a persisted-state feature imports at module scope is the whole cost of the feature.
This is not the dark-mode problem, and the solutions are opposite
Both read localStorage on startup. Both produce a wrong first frame if done naively. They need contradictory fixes, and applying one to the other is the most common way this goes wrong.
| Theme | Saved items, cart badge, dismissed banners | |
|---|---|---|
| Must be correct | Before first paint | After hydration |
| Wrong first frame | A visible flash of the wrong theme | An empty badge for one frame |
| Mechanism | Blocking inline <script> in <head> | useSyncExternalStore |
| Touches React | No — sets a class on <html> | Yes |
| Cost of the fix | One render-blocking script | One extra render after hydration |
Theme cannot wait for React, because a white flash before a dark page is the worst artifact on the page — that one needs a blocking script, which is the dark-mode-without-flash pattern. A wishlist count absolutely can wait, and using a blocking script for it means shipping render-blocking JavaScript to make a badge appear 200 ms sooner.
Ask which frame has to be right. That answers it.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
useState + useEffect to read storage | Extra render pass, no cross-component sync | useSyncExternalStore |
suppressHydrationWarning on real content | The mismatch is hidden, not fixed | Empty server snapshot |
Uncached getSnapshot | Infinite render loop | Memoize the parse on the raw string |
Returning a fresh [] for empty | Same loop, harder to spot | Module-level EMPTY constant |
Only listening to storage | Same-tab writes never update the UI | Dispatch your own event on write |
Ignoring e.key === null | Stale list after site data is cleared | Treat null key as "everything changed" |
Unguarded localStorage access | Crash in Safari private mode | try/catch every read and write |
| Trusting the stored payload | Tampered values reach href / img src | Validate shape and reject remote paths |
| Storing ids and resolving at render | A data module lands in the global bundle | Denormalize the fields you paint |
| No cap on entries | Storage grows until a quota error | Hard cap and de-duplicate on read |
| Rendering the badge before hydration | 0 flashes, then the real count | Gate on the hydrated flag |
Frequently asked questions
Why not a cookie, so the server can render it?
Because a cookie is sent on every request and makes the route dynamic — that is exactly the cost worth avoiding on a statically generated marketing or catalog page. A cookie is the right answer when the server genuinely must render the value (a cart total in the page, say). For a badge and a saved list, localStorage keeps the page static and keeps the payload out of every request.
Does this work with React Server Components?
Yes. The hook lives in a "use client" module; the Server Components around it are untouched. Keep the store module itself free of "use client" where you can, so its pure functions stay importable from tests and from server code without pulling the client boundary along.
Should the list sync to an account when the user signs in? It can, and the call sites do not have to change — swap the store's read/write for something that reconciles with a server list. Worth being clear about why it starts local: the feature exists for logged-out browsers deciding what to buy, which is most of the audience. Requiring an account to save something defeats the point of saving it.
What about sessionStorage or IndexedDB?
Same pattern, different backend. sessionStorage fires no cross-tab event, so the custom event becomes the only signal. IndexedDB is async, so the snapshot has to read from an in-memory mirror kept up to date by your own subscription — worth it above a few hundred KB, overkill below.
How big can a wishlist get before this hurts? The JSON parse is the cost, and it is memoized per raw string, so it runs once per change rather than once per render. A cap in the low hundreds keeps the parse trivial and doubles as a bound on storage growth. Ours is 200.
Templates that already ship the saved-items surface
Wiring this up is easier when the UI it belongs to exists. Three of ours ship the cart-and-wishlist pattern this post describes.
ASoc Nest is a furniture and home-decor store across eight categories with cart, wishlist and checkout already built. ASoc Tote is a bags-and-accessories storefront across six categories with sale-badged cards and add-to-cart. ASoc Steep is a single-origin tea and teaware store built around bestsellers and hand-thrown ware.
Browse the full set of Next.js shop templates or the Tailwind shop templates. For the state that genuinely belongs on the server instead, where cart state should live covers the cookie-versus-database-versus-Context decision.
