Zustand Persist Middleware: Hydration, Trade-offs, and Verification in Practice
Zustand's persist middleware handles localStorage hydration automatically, but production use requires deliberate choices: whitelist serializable state with partialize, gate renders during SSR with onRehydrateStorage, and verify with memoryStorage in tests. Here's the practical guide.
21 Sept 2026, 02:09 UTC

The Problem: State That Vanishes on Refresh
You've built a React feature with Zustand managing client-side state—maybe a shopping cart, user preferences, or a complex form draft. Everything works until the user accidentally refreshes the page or closes the tab. The state evaporates. LocalStorage seems like the obvious fix, but rolling your own serialization, hydration timing, and SSR guards quickly becomes a maintenance burden.
Zustand's persist middleware solves this with a store enhancer that handles serialization, storage, and rehydration automatically. But the defaults hide decisions that affect bundle size, hydration race conditions, and storage quotas. Here's how to use it deliberately.
How the Middleware Fits Into createStore
The persist middleware wraps your store creator via the create function's middleware chain. It intercepts setState calls, serializes the resulting state (or a subset), writes to storage, and on initialization reads back and hydrates before the first render.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useCartStore = create(
persist(
(set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] })
}),
{
name: 'cart-storage',
// storage: sessionStorage, // optional
}
)
)
The name becomes the localStorage key. By default the entire state tree is persisted. That's convenient but dangerous—functions, Promises, DOM nodes, and circular references will throw on JSON.stringify.
Controlling What Gets Persisted: partialize
Use partialize to whitelist only serializable slices. This is the single most important configuration for production stores.
const useCartStore = create(
persist(
(set) => ({
items: [],
_optimisticId: 0, // internal, not for persistence
addItem: (item) => set((state) => ({
items: [...state.items, { ...item, id: state._optimisticId++ }])
})),
clear: () => set({ items: [], _optimisticId: 0 })
}),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items })
}
)
)
Now _optimisticId stays in memory. The persisted JSON contains only { items: [...] }. On hydration, Zustand merges the stored partial state with the initial state from your creator function, so _optimisticId resets to 0—which is usually what you want for an internal counter.
Hydration Timing and the SSR Gap
By default, persist reads from localStorage synchronously during store creation. In a Next.js or Remix app, that code runs on the server where localStorage is undefined. The middleware guards against this by checking typeof window !== 'undefined', but the hydration still happens on the client before the first render.
If your UI depends on persisted state and server-fetched data, you can get a flash: the persisted cart renders, then the server data arrives and triggers a re-render. Two patterns help:
- Defer hydration with
onRehydrateStorageto set a flag, then gate rendering until both sources are ready. - Use
skipHydrationfor stores that should always start fresh on SSR (e.g., ephemeral UI state like open modals).
const useCartStore = create(
persist(
(set) => ({ items: [], addItem: /* ... */ }),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }),
onRehydrateStorage: () => (state) => {
// state is the hydrated store; you can dispatch an action here
state._hasHydrated = true
}
}
)
)
// In a component
const { items, _hasHydrated } = useCartStore()
if (!_hasHydrated) return <Skeleton />
The onRehydrateStorage callback receives the store instance after hydration completes. Setting a flag there lets you avoid the flash without blocking the whole app.
Storage Adapters: Testing and SSR Without localStorage
The storage option accepts any object with getItem, setItem, removeItem. Zustand exports createJSONStorage for custom adapters, and a memoryStorage helper for tests.
// test-utils.js
import { createJSONStorage } from 'zustand/middleware'
export const memoryStorage = createJSONStorage(() => {
let store = {}
return {
getItem: (name) => store[name],
setItem: (name, value) => { store[name] = value },
removeItem: (name) => { delete store[name] }
}
})
// In your test
import { persist } from 'zustand/middleware'
import { memoryStorage } from './test-utils'
const testStore = create(
persist((set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 })) }),
{ name: 'test', storage: memoryStorage }
)
)
This runs the full persist lifecycle—serialization, storage round-trip, hydration—without a browser. You can assert store.getState().count after a simulated reload by creating a second store instance with the same name.
Worked Example: Persisting a Filterable Product List
Imagine a product catalog with user-applied filters (category, price range, sort order) that should survive navigation. The filter state is plain objects—perfect for persistence. But the product list itself comes from an API and shouldn't be stored.
const useProductStore = create(
persist(
(set, get) => ({
filters: { category: 'all', minPrice: 0, maxPrice: 1000, sort: 'name' },
products: [],
setFilters: (filters) => set({ filters }),
setProducts: (products) => set({ products }),
resetFilters: () => set({ filters: get().initialFilters }),
initialFilters: { category: 'all', minPrice: 0, maxPrice: 1000, sort: 'name' }
}),
{
name: 'product-filters',
partialize: (state) => ({ filters: state.filters }),
version: 1 // enables migration later
}
)
)
Only filters persists. products and initialFilters stay in memory. The version field prepares for future migrations—if you change the filter shape, you can add a migrate function that receives the persisted state and version number.
Trade-offs and Limitations
- Bundle cost: ~1 KB gzipped. Negligible for most apps, but measurable in ultra-lightweight contexts.
- Storage quota: Browsers enforce ~5 MB per origin. A store holding large arrays (e.g., cached API responses) can hit
QuotaExceededError. Monitor withtry/catcharoundsetItemor use a custom storage adapter that logs failures. - Race conditions: If an async action fires before hydration completes, its
setStatemay be overwritten by the incoming persisted state. TheonRehydrateStoragecallback is your synchronization point—delay async side effects until it fires. - No encryption: Data sits in plain text in localStorage. Don't persist tokens, PII, or secrets.
Verification Checklist Before Shipping
- Inspect storage: Open DevTools → Application → LocalStorage. Confirm the key exists, the JSON parses, and the shape matches
partializeoutput. - Reload test: Hard refresh (Cmd+Shift+R). Verify
store.getState()matches pre-reload state for persisted slices. - SSR test: Disable JavaScript or view source. Ensure no hydration errors and no flash of persisted content before data loads.
- Memory storage test: Run your unit suite with
memoryStorage. Assert that a second store instance with the same name receives the persisted state. - Quota stress: In development, artificially inflate state size (e.g.,
localStorage.setItem('cart-storage', 'x'.repeat(6_000_000))) and verify graceful degradation.
Closing: Persist Intentionally
Zustand's persist middleware removes boilerplate but doesn't remove decisions. Choose partialize over blocklisting. Gate renders with onRehydrateStorage when SSR and client state intersect. Test with memoryStorage so your CI catches serialization regressions. The middleware is a power tool—use it precisely, and your users' carts, filters, and drafts will survive the inevitable refresh.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.