Zustand Persist Middleware: Configuration, Serialization Limits, and Recovery Patterns
Configure Zustand's persist middleware to survive reloads without serializing functions, leaking secrets, or corrupting state on migration. Includes a worked cart-only example, migration pattern, and verification checklist.
28 Sept 2025, 09:51 UTC

The Problem: State Loss on Page Reload
Client-side state disappears when users refresh or close the browser. Zustand's persist middleware solves this by serializing the store to a storage adapter (typically localStorage) and rehydrating it on the next load. The useful takeaway: persist only what must survive a reload, and verify the serialized shape before shipping.
How Persist Works
The middleware wraps your store creator. On every state change, it serializes the current state tree to JSON and writes it to the configured storage. On initialization, it reads the stored value, parses it, and replaces the initial state with the parsed result. This happens synchronously before the first render unless you opt into asynchronous storage.
Key configuration options:
name: the storage key (required)storage: an adapter implementinggetItem,setItem,removeItem(defaults tolocalStorage)partially: a function returning the subset of state to persistonRehydrateStorage: callback fired after hydration completesversionandmigrate: for schema migrations across releases
Worked Example: Persisting a Cart Slice Only
Imagine an e-commerce store with a large product catalog, a user session, and a shopping cart. Only the cart needs to survive a reload. The catalog is re-fetched; the session is validated via token.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CartItem { id: string; qty: number }
interface State {
catalog: Product[]
session: Session | null
cart: CartItem[]
addToCart: (id: string) => void
clearCart: () => void
}
const useStore = create()(
persist(
(set) => ({
catalog: [],
session: null,
cart: [],
addToCart: (id) => set((s) => {
const existing = s.cart.find((i) => i.id === id)
if (existing) {
return { cart: s.cart.map((i) => i.id === id ? { ...i, qty: i.qty + 1 } : i) }
}
return { cart: [...s.cart, { id, qty: 1 }] }
}),
clearCart: () => set({ cart: [] }),
}),
{
name: 'shop-cart',
// Persist only the cart slice
partially: (state) => ({ cart: state.cart }),
// Optional: custom storage for SSR safety
storage: {
getItem: (name) => {
if (typeof window === 'undefined') return null
return localStorage.getItem(name)
},
setItem: (name, value) => localStorage.setItem(name, value),
removeItem: (name) => localStorage.removeItem(name),
},
// Validate restored cart shape
onRehydrateStorage: () => (state) => {
if (state && !Array.isArray(state.cart)) {
console.warn('Invalid cart shape, resetting')
state.cart = []
}
},
version: 1,
}
)
)
Run this in a browser console or React component. After adding items and refreshing, open DevTools → Application → Local Storage → shop-cart. You should see {"state":{"cart":[{"id":"sku-123","qty":2}]},"version":1}. The catalog and session keys are absent because partially excluded them.
Serialization Limits
JSON serialization drops anything not representable in JSON: functions, Promises, undefined, Symbol, BigInt, circular references, and class instances. If your state contains these, persist will either throw (during setItem) or silently drop the keys (during parse).
Common offenders:
- Date objects → store as ISO strings or timestamps
- Map/Set → convert to arrays/objects before persisting
- React elements or DOM nodes → never persist these
- Callbacks or thunks → keep them out of state entirely
If you must persist a Date, transform it in partially:
partially: (state) => ({
cart: state.cart,
lastUpdated: state.lastUpdated?.toISOString() ?? null
})
And revive it in onRehydrateStorage:
onRehydrateStorage: () => (state) => {
if (state?.lastUpdated) state.lastUpdated = new Date(state.lastUpdated)
}
Recovery Patterns
1. Versioned Migrations
When the state shape changes, bump version and provide migrate:
version: 2,
migrate: (persistedState, version) => {
if (version === 1) {
// v1 stored cart as { items: [] }, v2 uses { lines: [] }
return { ...persistedState, cart: { lines: persistedState.cart?.items ?? [] } }
}
return persistedState
}
The middleware calls migrate before onRehydrateStorage. Test by manually editing localStorage to an old version and reloading.
2. Handling Corrupted Storage
If localStorage contains invalid JSON or a mismatched shape, the middleware throws during initialization. Wrap the store creation in a try/catch and fall back to a fresh store:
let useStore
try {
useStore = create()(persist(...))
} catch (e) {
console.error('Persist init failed, clearing storage', e)
localStorage.removeItem('shop-cart')
useStore = create()(persist(...)) // retry without persisted data
}
This runs in the module scope before any component mounts.
3. Asynchronous Storage (IndexedDB, AsyncStorage)
For React Native or large state, use an async adapter. The middleware will delay hydration until the promise resolves. Components reading the store before hydration see the initial state; subscribe to onRehydrateStorage to know when it's ready:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
const useStore = create()(
persist(
(set) => ({ ... }),
{
name: 'app-state',
storage: AsyncStorage,
onRehydrateStorage: () => (state) => {
// Safe to dispatch side effects now
state?.hydrated?.()
},
}
)
)
Common Mistakes
- Mutating state outside actions:
useStore.getState().cart.push(item)bypasses Zustand's setter, so the middleware doesn't intercept the change. The next persist writes stale data. Always usesetor the returned action. - Overusing
partially: Excluding too much forces manual re-fetching on reload. If the user expects their filter preferences to persist, include them. - Assuming synchronous hydration: With async storage, the first render uses initial state. Guard derived computations:
const cart = useStore((s) => s.hydrated ? s.cart : []). - Storing secrets:
localStorageis readable by any script on the origin. Never persist tokens, PII, or encryption keys.
Verification Checklist
Before merging a persist-enabled store:
- Open DevTools → Application → Local Storage. Confirm the key exists and the JSON parses without error.
- Add a
console.loginonRehydrateStorageand verify it fires once per reload, after the first render. - Write a unit test (Vitest/Jest) that:
- Creates the store
- Dispatches actions
- Calls
localStorage.setItemwith the serialized state - Creates a fresh store instance
- Asserts
useStore.getState().cartmatches the persisted value
- Test migration: write an old-version payload to storage, load the app, verify the migrated shape.
Limitations
- No partial updates: The entire persisted slice writes on every change. For high-frequency updates (dragging, typing), debounce or batch with a separate ephemeral store.
- Storage quota:
localStorageis ~5 MB. Large catalogs or histories will hit quota. Use IndexedDB viaidborzustand/middleware/indexedDB. - SSR mismatch: On Next.js,
localStorageis unavailable during server render. The custom storage adapter shown above returnsnullon server, causing a hydration mismatch if the client reads persisted data before the first render. UseuseEffectto gate reads or a library likezustand/middleware/persistwithskipHydration.
Practical Verification Step
After implementing, run this in the browser console on a fresh page load:
// 1. Check stored raw value
console.log('Raw:', localStorage.getItem('shop-cart'))
// 2. Parse and inspect shape
const parsed = JSON.parse(localStorage.getItem('shop-cart') ?? '{}')
console.log('Parsed:', parsed)
console.log('Cart array?', Array.isArray(parsed.state?.cart))
// 3. Compare with live store
console.log('Live cart:', useStore.getState().cart)
console.log('Match:', JSON.stringify(useStore.getState().cart) === JSON.stringify(parsed.state?.cart))
If the last line logs true, persistence is working correctly. If false, check partially, serialization transforms, or migration logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.