Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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.

ThemeSaved items, cart badge, dismissed banners
Must be correctBefore first paintAfter hydration
Wrong first frameA visible flash of the wrong themeAn empty badge for one frame
MechanismBlocking inline <script> in <head>useSyncExternalStore
Touches ReactNo — sets a class on <html>Yes
Cost of the fixOne render-blocking scriptOne 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

MistakeWhat happensFix
useState + useEffect to read storageExtra render pass, no cross-component syncuseSyncExternalStore
suppressHydrationWarning on real contentThe mismatch is hidden, not fixedEmpty server snapshot
Uncached getSnapshotInfinite render loopMemoize the parse on the raw string
Returning a fresh [] for emptySame loop, harder to spotModule-level EMPTY constant
Only listening to storageSame-tab writes never update the UIDispatch your own event on write
Ignoring e.key === nullStale list after site data is clearedTreat null key as "everything changed"
Unguarded localStorage accessCrash in Safari private modetry/catch every read and write
Trusting the stored payloadTampered values reach href / img srcValidate shape and reject remote paths
Storing ids and resolving at renderA data module lands in the global bundleDenormalize the fields you paint
No cap on entriesStorage grows until a quota errorHard cap and de-duplicate on read
Rendering the badge before hydration0 flashes, then the real countGate 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.

Keep reading

Tutorial11 min read

Dynamic Open Graph Images in Next.js with ImageResponse

A metadata route under a dynamic segment needs its own generateStaticParams, or every card renders per request. That trap, the Satori CSS subset, fonts, and how to verify a crawler sees it.

Read more