Zustand Persist with Versioned Migrations: Evolving Client State Without Data Loss
Zustand's persist middleware stores a frozen snapshot of your state in users' browsers. Use versioned migrations, partialize, and storage guards to evolve that state safely across releases.
01 Dec 2025, 22:33 UTC

The problem: your stored state outlives your schema
Once you ship a Zustand store with persist, users' browsers hold a frozen snapshot of your state shape. Months later you rename a field, restructure a nested object, or change a default — and the next release hydrates stale data into a store that expects something else. The usual symptoms are subtle: undefined values where the UI expects arrays, silently dropped preferences, or a crash on first render after deploy.
The useful takeaway: persist already ships with a version and migrate mechanism designed exactly for this. If you set a version on day one and write small, pure migration functions per release, hydration becomes a controlled upgrade path instead of a gamble.
Prerequisites
- Zustand v4 or later (the examples assume the v4/v5 middleware API; check your installed version with
npm list zustand). - A client-rendered web app, or an SSR setup where you can guard storage access (covered below).
- State that is JSON-serializable — no functions, class instances, Maps, or circular references in the persisted slice.
Start with partialize, not the whole store
By default persist serializes everything, including actions. Actions are functions, so JSON serialization drops them — usually harmless, but it means your stored blob is noisy and any future non-serializable state you add will silently vanish or break writes. Use partialize to persist only the data slice:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface SettingsState {
theme: 'light' | 'dark';
itemsPerPage: number;
setTheme: (t: 'light' | 'dark') => void;
setItemsPerPage: (n: number) => void;
}
export const useSettingsStore = create()(
persist(
(set) => ({
theme: 'light',
itemsPerPage: 20,
setTheme: (theme) => set({ theme }),
setItemsPerPage: (itemsPerPage) => set({ itemsPerPage }),
}),
{
name: 'app-settings',
version: 2,
partialize: (state) => ({
theme: state.theme,
itemsPerPage: state.itemsPerPage,
}),
migrate: (persisted, version) => {
const state = persisted as { theme?: string; itemsPerPage?: number; pageSize?: number };
if (version < 2) {
// v1 stored the field as `pageSize`; v2 renamed it.
if (typeof state.pageSize === 'number' && state.itemsPerPage === undefined) {
state.itemsPerPage = state.pageSize;
}
delete state.pageSize;
}
return state as { theme: 'light' | 'dark'; itemsPerPage: number };
},
}
)
);Run this wherever your store currently lives — it's a module-level change, no special permissions needed. The meaningful placeholders are the storage name (pick something stable; renaming it orphans existing data) and the version number.
Migration rules that keep you out of trouble
- Bump
versionon every persisted-shape change. If the stored version is lower than the configured version,migrateruns. If they match, the state is merged as-is. - Keep migrate pure and defensive. It receives the raw persisted object, which may predate fields you now assume exist. Guard every access (
typeof x === 'number') and never throw — a throwing migrate leaves the store in its initial state, which is survivable, but a crash during module evaluation is not. Note the return type is your persisted slice, not the full store; actions come from the creator function. - Chain migrations by version. A user skipping three releases will hit
migrateonce with their old version number. Writeif (version < 2) {...} if (version < 3) {...}blocks so each step builds on the previous one. - When in doubt, drop the field. Returning a partial object is fine — Zustand merges migrated state over your defaults, so missing keys fall back to initial values.
SSR and storage guards
persist defaults to localStorage, which throws a ReferenceError during server rendering. Use createJSONStorage with a guarded accessor:
storage: createJSONStorage(() =>
typeof window !== 'undefined' ? window.localStorage : (undefined as unknown as Storage)
)Passing a storage that isn't available causes persist to skip hydration gracefully in current versions, but verify this against your installed version — behavior here has changed across releases, so treat it as a check-before-ship item. For SSR apps, also gate rendering of persisted-dependent UI on hydration, otherwise server HTML and first client render will mismatch:
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const unsub = useSettingsStore.persist.onFinishHydration(() => setHydrated(true));
setHydrated(useSettingsStore.persist.hasHydrated());
return unsub;
}, []);Verifying the migration actually works
- Write path: update state in the running app, then open DevTools → Application → Local Storage and confirm the
app-settingskey contains JSON with{ state: {...}, version: 2 }. - Migration path: manually edit the stored value to the old shape — set
version: 1and renameitemsPerPagetopageSize— then reload. The store should show the migrated value and storage should be rewritten at version 2. - Fresh-install path: clear the key and reload. The store must initialize from defaults with no console errors.
- SSR path: run your server build and confirm the page renders without
localStorage is not defined.
Limitations and recovery
Persisted writes are fire-and-forget; storage quota errors can fail silently, so don't treat persisted state as a source of truth for anything you can't afford to lose. Cross-tab sync is not automatic — two tabs can overwrite each other, and you need a storage event listener if that matters. If a bad deploy ships a corrupting migration, the practical recovery is to bump version again with a migrate that reconstructs the slice from defaults, or change the storage name to abandon the poisoned data entirely. Neither restores lost user data, which is why migrations deserve the same review rigor as database migrations.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.