How to Persist Zustand State to localStorage or sessionStorage Using the Persist Middleware
The Zustand persist middleware wraps your store creator to automatically synchronize state with browser storage, using window.localStorage by default while allowing you to swap in sessionStorage or custom adapters via the storage option.
In the pmndrs/zustand repository, the persist middleware provides a declarative way to make your global state survive page reloads. By intercepting the store initializer, it handles serialization, storage I/O, and rehydration without requiring boilerplate in your components.
Architecture of the Persist Middleware
The middleware is implemented in [src/middleware/persist.ts](https://github.com/pmndrs/zustand/blob/main/src/middleware/persist.ts). It operates by wrapping the vanilla store creator and injecting a storage synchronization layer.
Storage Adapter Creation
At its core, the middleware uses createJSONStorage, defined at lines 31-61, to normalize any storage object into a JSON-aware interface:
export function createJSONStorage<S, R = unknown>(
getStorage: () => StateStorage<R>,
options?: JsonStorageOptions,
): PersistStorage<S, unknown> | undefined { … }
By default, persist initializes this with window.localStorage:
// Default storage initialization (persist.ts ~line 89-91)
let options = {
storage: createJSONStorage<S, void>(() => window.localStorage),
// … other defaults
}
If the getter throws (e.g., during server-side rendering where window is undefined), the middleware gracefully falls back to no-op behavior.
Hydration and Synchronization
When the store mounts, the middleware executes a hydrate cycle:
- Read – Retrieves the stored JSON string using
storage.getItem. - Migrate – If a
versionmismatch is detected, runs the user-providedmigratefunction. - Merge – Combines the persisted slice with the fresh initial state using the optional
mergestrategy. - Subscribe – Attaches a listener to
setStatethat writes back to storage on every change, applyingpartializeto strip non-persistable fields.
The middleware also exposes a control API on store.persist, including setOptions, clearStorage, rehydrate, and lifecycle hooks like onFinishHydration.
Persisting to localStorage (Default Behavior)
To persist state across browser sessions, import persist and provide a unique name key:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useSettingsStore = create(
persist(
(set) => ({
theme: 'dark',
toggleTheme: () => set((state) => ({
theme: state.theme === 'dark' ? 'light' : 'dark'
})),
}),
{
name: 'user-settings', // Key in localStorage
partialize: (state) => ({ theme: state.theme }), // Store only theme
}
)
)
The partialize option accepts a function that receives the full state and returns a shallow object containing only the fields you want serialized. By default, the entire state is stringified and written to localStorage after every update.
Switching to sessionStorage
To use sessionStorage instead, pass a custom storage option using createJSONStorage:
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export const useSessionStore = create(
persist(
(set) => ({
cart: [] as string[],
addItem: (id) => set((state) => ({ cart: [...state.cart, id] })),
}),
{
name: 'shopping-cart',
storage: createJSONStorage(() => window.sessionStorage),
}
)
)
Because sessionStorage shares the same interface as localStorage (getItem, setItem, removeItem), createJSONStorage handles JSON serialization automatically. This pattern works for any custom storage object implementing the StateStorage interface.
Advanced Configuration: Versioning and Migration
For long-lived applications where state shape evolves, the middleware supports schema versioning:
export const useVersionedStore = create(
persist(
(set) => ({
user: { id: '', name: '', email: '' },
setUser: (user) => set({ user }),
}),
{
name: 'user-store',
version: 2, // Current schema version
migrate: (persistedState, version) => {
if (version === 1) {
// Migrate from v1: add email field
return {
...persistedState,
user: { ...persistedState.user, email: '' }
}
}
return persistedState
},
}
)
)
The migrate function receives the stored state and its version number, allowing you to transform legacy data before it reaches your components. If omitted, the middleware overwrites old data with the fresh initial state when versions differ.
SSR-Safe Hydration with skipHydration
In server-side rendering environments, attempting to access localStorage during the initial render throws an error. The skipHydration option defers rehydration until the client bundle executes:
export const useSSRStore = create(
persist(
(set) => ({
data: null,
setData: (d) => set({ data: d }),
}),
{
name: 'ssr-store',
skipHydration: true, // Prevents automatic rehydration
}
)
)
// In your client-side component:
useEffect(() => {
useSSRStore.persist.rehydrate()
}, [])
Calling store.persist.rehydrate() manually triggers the same read-and-merge logic that normally runs automatically on mount.
Runtime Storage Management
The middleware attaches utility methods to store.persist for imperative control:
clearStorage()– Removes the stored entry entirely.setOptions(newOptions)– Dynamically changes configuration, including swapping storage targets.hasHydrated()– Returns a boolean indicating whether hydration has completed.onFinishHydration(callback)– Registers a listener that fires once when hydration finishes.
Example of clearing persisted data on user logout:
const logout = () => {
useSessionStore.persist.clearStorage()
// Redirect to login...
}
Summary
- The persist middleware in [
src/middleware/persist.ts](https://github.com/pmndrs/zustand/blob/main/src/middleware/persist.ts) wraps stores to sync state with browser storage. - createJSONStorage adapts
localStorage,sessionStorage, or custom storage objects to handle JSON serialization. - Configure persistence with
name,storage,partialize,version, andmigrateoptions. - Use
skipHydrationfor SSR compatibility, then callstore.persist.rehydrate()client-side. - Access
store.persist.clearStorage()and other controls for runtime storage management.
Frequently Asked Questions
How do I persist only specific fields of my Zustand store?
Use the partialize option in your persist configuration. This function receives the entire state object and should return a shallow object containing only the fields you want stored. Fields omitted from the returned object are not written to storage and reset to their initial values on reload.
Can I use sessionStorage instead of localStorage with Zustand persist?
Yes. Pass storage: createJSONStorage(() => window.sessionStorage) in your persist options. The createJSONStorage helper accepts any object implementing getItem, setItem, and removeItem, making it compatible with sessionStorage, localStorage, or custom async storage wrappers.
What happens if I change the structure of my stored state?
Specify a version number in your persist options and provide a migrate function. When the middleware detects a version mismatch during hydration, it executes your migration logic to transform the old data shape into the new one before merging it with the store.
How do I prevent hydration errors during server-side rendering?
Set skipHydration: true in the persist options. This prevents the middleware from attempting to read localStorage during the server render. Then, in a client-side useEffect, call store.persist.rehydrate() to load the stored state once the browser environment is available.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →