Zustand persist: Designing the Storage Boundary
persist turns every byte in localStorage into untrusted input on the next load. Here is the smallest design that survives a deploy, a tampered value and a full disk.
17 Sept 2026, 13:48 UTC

The decision persist actually asks you to make
Enabling Zustand's persist middleware takes three lines. Deciding what belongs in browser storage, what happens when that data is stale or hostile, and how the app behaves when a write fails is the real work. persist is a synchronization layer between a store and a storage engine — usually localStorage or sessionStorage — and every byte you route through it becomes untrusted input on the next page load.
This note covers the smallest design that survives a deploy, a tampered value and a full disk, plus the conditions that should push you off localStorage entirely.
What the middleware does on each side of the boundary
Two flows matter.
Write path. Any store update triggers the middleware's subscriber. It runs your partialize function (if provided), serializes the result with JSON.stringify, and calls setItem on the storage adapter. With localStorage that write is synchronous and happens on the main thread.
Read path (hydration). At store creation the middleware calls getItem, parses the JSON, and merges it into the initial state. If a version is configured and the stored version is lower, migrate runs first. The result becomes the store's starting state — before your components render, or shortly after, depending on how the store is created.
Version assumption: the option names below (partialize, version, migrate, merge, createJSONStorage, onRehydrateStorage, skipHydration) are the documented surface of the middleware in Zustand v4 and v5. Confirm against the docs for the version in your lockfile before copying the example.
Requirements before code
- Which fields must survive a refresh? Usually a draft, a filter, a theme — not a server‑owned entity.
- Which fields must never be written to disk? Access tokens, personal data, anything a third‑party script on the page could read.
- What is the maximum realistic payload? A few kilobytes is comfortable; hundreds of kilobytes is not.
- What should happen when the stored shape no longer matches the code?
The smallest suitable design
One store, one storage key, an explicit allowlist, and a version number you bump whenever the persisted shape changes.
// src/store/session.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type SessionState = {
token: string | null; // server‑owned, never persisted
draft: string; // local‑only, persisted
setDraft: (draft: string) => void;
signOut: () => void;
};
export const useSession = create<SessionState>()(
persist(
(set) => ({
token: null,
draft: '',
setDraft: (draft) => set({ draft }),
signOut: () => set({ token: null, draft: '' }),
}),
{
name: 'session-v3', // the localStorage key
version: 3, // bump when the shape changes
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ draft: s.draft }),
migrate: (persisted, fromVersion) => {
if (fromVersion < 3) return { draft: '' };
return persisted as { draft: string };
},
}
)
);
Notes on the choices:
partializeis the allowlist. It is the single most valuable option here, because it turns "the whole store is persisted" into a reviewable list.versionplusmigrateis your schema contract. Without it, a deploy that renames a field leaves old JSON in place and the app hydrates a shape the code no longer expects.createJSONStoragewraps the raw storage sogetItemparses andsetItemstringifies. If you supply your own adapter instead, you own both halves of that contract.- The example is illustrative and was not executed here. Treat the types as a sketch and let your compiler check them.
The trust boundary
Anything in localStorage is readable and writable by any script running on the origin, and by the user through DevTools. Hydration therefore consumes untrusted input. Two guards cover most of it:
- Allowlist on write (
partialize) so secrets never reach storage. - Validate on read.
migratecan return a sanitized default when the version is unknown, and a custommergecan ignore persisted keys that fail a type check rather than spreading them into state.
If a field's value can change the outcome of a permission check, it does not belong in client storage at all — re‑derive it from the server on load.
Operational checks
Run these in a browser against a development build:
- Open DevTools → Application → Local Storage → your origin. Confirm exactly one key (
session-v3) and thattokenis absent. - Type into the draft field, then re‑read the key. It should update on each change; if it does not, the store update is not reaching the middleware.
- Refresh. The draft should reappear and the app should not flash an empty state.
- Tamper test: edit the stored value to invalid JSON, then refresh. Record what happens. If the app throws instead of falling back, add a
mergeormigrateguard. - Quota test: write a large string into the key until the browser rejects it, then use the app. If the failure surfaces as an unhandled error, wrap your own storage adapter's
setItemin a try/catch and degrade to in‑memory state.
The documented hooks cover hydration errors — onRehydrateStorage receives an error argument. Write failures are easier to handle in a custom adapter you control than through middleware options, so verify what your installed version exposes before relying on either.
Failure modes
| Symptom | Likely cause | Check |
|---|---|---|
| Stale fields after deploy | Shape changed, version not bumped | Read the stored JSON; compare to the current type |
| Token visible in DevTools | partialize missing or too broad | Application tab, inspect the key |
| Typing feels laggy | Large slice serialized on every update | Shrink partialize; profile the write |
| Blank screen on load | Unvalidated hydrated value | Tamper test above |
| Tabs disagree | No cross‑tab sync | Open two tabs, change state in one |
localStorage's quota is commonly cited as around 5 MB per origin and varies by browser; treat any specific number as approximate and test in your target browsers.
When to change the design
- Payload grows past a few hundred kilobytes. Switch to an async adapter over IndexedDB. The middleware accepts a custom storage object; the trade‑off is that hydration becomes asynchronous, so components may render before state is restored.
- Data must not be readable by page scripts. Client‑side encryption with a key that also lives in the browser does not solve this. Move the value to a server session or an httpOnly cookie.
- Server rendering. Storage does not exist during SSR.
skipHydrationdefers hydration so you can trigger it after mount, avoiding a server/client mismatch. - Multiple tabs must agree. Subscribe to the window
storageevent and rehydrate, or accept that the last writer wins. - Several stores persist overlapping data. Consolidate into one persisted slice; duplicated keys drift.
Limitations
persist is a convenience layer, not a database. It offers no transactions, no conflict resolution and no guarantee that a write completed before the tab closed. For anything where losing a write is unacceptable, persist to the server and treat local storage as a cache. Because the behavior described here comes from general knowledge of the middleware rather than a run against a specific release, treat the option names and error handling as items to confirm against your installed version's documentation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.