Zustand 07 ⚛️ Middleware Persist — Persisting store data to localStorage
The persist middleware writes a Zustand store to a storage engine and reads it back when the application starts. The default storage is localStorage, and the default serialization is JSON. The effect is that state survives page reloads and browser sessions: a cart does not empty, a theme preference does not reset, and a draft does not disappear when the tab is closed and reopened . The middleware is imported from zustand/middleware and wraps the state creator, so the store’s definition changes from create((set) => ({ ... })) to create(persist((set) => ({ ... }), options)).
The previous chapter covered nested state updates. Persistence introduces a new dimension: the store is no longer just an in-memory object. It has a lifecycle that includes hydration—the process of reading the stored value and merging it with the initial state—and that lifecycle interacts with the render cycle, the storage engine, and the schema of the persisted data. This chapter covers the basic configuration, the partialize option that controls which fields are stored, the version and migrate options that handle schema changes, and the skipHydration option that defers the read until after the first render.
Key point: The name option is the only required configuration, and it is the key under which the state is stored. The persisted entry is JSON in the form {"state": {...}, "version": 0}, and functions are not stored; the actions come from the code at startup . By default, the entire state is persisted, including fields that should not survive a reload, such as a modal’s open state. The partialize option restricts persistence to the fields that should be stored.
Why persist requires explicit configuration
The full-state problem.
By default, persist stores every field in the state. A cart store with an isOpen boolean will restore the cart drawer in the open state after a reload, which is rarely what the user expects. A form store with a submitting flag will restore the flag as true, leaving the form in a permanent loading state. The partialize option selects the fields that should be stored and excludes the transient ones . This is not an optimization; it is the difference between a persistence layer that restores meaningful data and one that restores UI accidents.
The schema-change problem.
The shape of the store changes as the application evolves. A field is renamed, a nested object is flattened, a new field is added with a default value. The stored data is in the old shape, and the code expects the new shape. Without versioning, the middleware reads the old data and merges it into the new state, producing fields that are undefined or missing. The version option assigns a number to the current schema, and the migrate function transforms the persisted state from the old version to the new one . A version mismatch without a migration function causes the stored state to be discarded, which is safe but loses the user’s data.
The hydration-timing problem.
Hydration is the process of reading the stored value and merging it with the initial state. For synchronous storage like localStorage, hydration happens at store creation, before the first render. For asynchronous storage, hydration happens later, and the store is not hydrated at the initial render . In a server-rendered application, the server renders with the initial state, and the client hydrates with the stored state, producing a mismatch between the server’s HTML and the client’s first render. The skipHydration option disables the automatic hydration, and the application calls store.persist.rehydrate() after mount to apply the stored state without the mismatch .
a. Basic persistence with localStorage
The persist middleware wraps the state creator and accepts an options object. The name option is the storage key, and it must be unique in the application.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CartState {
items: { id: number; name: string; quantity: number }[]
isOpen: boolean
addItem: (item: { id: number; name: string }) => void
toggleOpen: () => void
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
isOpen: false,
addItem: (item) =>
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
})),
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
}),
{
name: 'cart-storage', // localStorage key
}
)
)
The name option is the only required configuration. The middleware reads from localStorage on creation and writes to it on every set call. The stored entry is JSON with the shape {"state": {...}, "version": 0}. The functions in the store are not stored because JSON cannot represent them; they are restored from the code when the store is created . Since Zustand v5, the initial state is not written to storage on creation; the entry appears on the first set call .
The createJSONStorage helper is the default storage adapter, but it can be used explicitly to select a different storage engine.
import { createJSONStorage, persist } from 'zustand/middleware'
persist(
(set) => ({ /* ... */ }),
{
name: 'draft-storage',
storage: createJSONStorage(() => sessionStorage), // Use sessionStorage instead
}
)
The sessionStorage engine clears the data when the tab closes, which is appropriate for drafts and temporary state. The localStorage engine persists across sessions, which is appropriate for preferences and carts . The createJSONStorage helper accepts a function that returns the storage engine, so the choice can be deferred until runtime.
b. Partial persistence with partialize
The partialize option returns an object containing only the fields that should be persisted. The fields that are not returned are excluded from the stored entry and are reset to their initial values on reload.
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
isOpen: false,
addItem: (item) =>
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
})),
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
}),
{
name: 'cart-storage',
partialize: (state) => ({ items: state.items }), // Only persist items
}
)
)
With partialize, the isOpen boolean is not stored. When the page reloads, the cart items are restored, but the drawer is closed because isOpen comes from the initial state. This is the correct behavior for a transient UI flag . The partialize function receives the full state and returns the subset to persist; the actions are never persisted because they are functions, but the partialize function must still return the data fields, not the actions.
The merge option controls how the persisted state is combined with the initial state. The default is a shallow merge, which replaces the top-level keys in the initial state with the persisted values. If the store has nested objects and only a subset of their fields was persisted, the shallow merge can leave the nested object with missing fields. A custom merge function can perform a deep merge, but it must be provided explicitly .
c. Versioning and migration
The version option assigns a number to the current schema. The migrate function transforms the persisted state from an older version to the current one.
interface CartItem {
id: number
name: string
quantity: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
}
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) =>
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
})),
}),
{
name: 'cart-storage',
version: 1,
migrate: (persisted, version) => {
if (version === 0) {
// Version 0 stored items with a 'title' field
// Version 1 uses 'name' instead
const old = persisted as { items: { id: number; title: string }[] }
return {
items: old.items.map((item) => ({
id: item.id,
name: item.title,
quantity: 1,
})),
}
}
return persisted as CartState
},
}
)
)
The migrate function receives the persisted state and the version number that was stored. It returns the state in the current shape. If the stored version matches the current version, the migrate function is not called. If the stored version is greater than the current version, the persisted state is discarded . The migration function must handle every version gap, from the stored version up to the current one, either by chaining transformations or by checking each version and applying the appropriate changes.
The version number should be incremented every time the persisted shape changes in a way that is not backward compatible. Adding a new field with a default value does not require a version bump if the default is applied by the merge; renaming or removing a field does .
Complete Example Session
// ============================================
// PART 1: THE STORE WITH PERSIST
// ============================================
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface Settings {
theme: 'light' | 'dark'
language: string
sidebarOpen: boolean
}
interface SettingsState extends Settings {
setTheme: (theme: Settings['theme']) => void
setLanguage: (language: string) => void
toggleSidebar: () => void
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'light',
language: 'en',
sidebarOpen: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}),
{
name: 'settings-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
theme: state.theme,
language: state.language,
// sidebarOpen is excluded; it resets on reload
}),
version: 1,
migrate: (persisted, version) => {
if (version === 0) {
// Version 0 used 'lang' instead of 'language'
const old = persisted as { theme: string; lang: string }
return { theme: old.theme, language: old.lang }
}
return persisted as Settings
},
}
)
)
// ============================================
// PART 2: THE STORE WITH SKIP HYDRATION (SSR)
// ============================================
export const useThemeStore = create<{ theme: 'light' | 'dark'; setTheme: (t: 'light' | 'dark') => void }>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
skipHydration: true, // Defer hydration until after mount
}
)
)
// ============================================
// PART 3: MANUAL REHYDRATION IN A CLIENT COMPONENT
// ============================================
import { useEffect } from 'react'
import { useThemeStore } from './themeStore'
function AppInitializer() {
useEffect(() => {
// Hydrate the store after the first client render
useThemeStore.persist.rehydrate()
}, [])
return null
}
// ============================================
// PART 4: CHECKING HYDRATION STATUS
// ============================================
function ThemeDisplay() {
const theme = useThemeStore((state) => state.theme)
const hasHydrated = useThemeStore.persist.hasHydrated()
if (!hasHydrated) {
return <span>Loading theme...</span> // Render before hydration
}
return <span>{theme}</span> // Render after hydration
}
// ============================================
// PART 5: THE onRehydrateStorage CALLBACK
// ============================================
export const useAuthStore = create<{ token: string | null; setToken: (t: string | null) => void }>()(
persist(
(set) => ({
token: null,
setToken: (token) => set({ token }),
}),
{
name: 'auth-storage',
onRehydrateStorage: () => (state) => {
if (state) {
console.log('Auth state hydrated:', state.token ? 'logged in' : 'logged out')
}
},
}
)
)
The five parts demonstrate the persistence surface: the basic store with partialize and migrate, the SSR-safe store with skipHydration, the manual rehydration in a client component, the hydration status check, and the onRehydrateStorage callback. The critical distinction is between Part 1 and Part 2: the first store hydrates synchronously at creation, and the second defers hydration until the application explicitly requests it.
Quick Reference
Persist Options
| Option | Required | Purpose |
|---|---|---|
name | Yes | localStorage key |
storage | No | Storage engine (default: localStorage) |
partialize | No | Select fields to persist |
version | No | Schema version number |
migrate | No | Transform old persisted state |
merge | No | Custom merge of persisted and initial state |
skipHydration | No | Defer hydration until manual call |
onRehydrateStorage | No | Callback before/after hydration |
Storage Engines
| Engine | Persistence | Use Case |
|---|---|---|
localStorage | Across sessions | Preferences, carts |
sessionStorage | Until tab closes | Drafts, temporary state |
| Custom | Depends on implementation | IndexedDB, URL params |
Hydration Methods
| Method | Purpose |
|---|---|
store.persist.rehydrate() | Manually trigger hydration |
store.persist.hasHydrated() | Check if hydration completed |
store.persist.onFinishHydration(fn) | Subscribe to hydration completion |
Best Practices
✅ Do This:
// Use partialize to exclude transient fields
partialize: (state) => ({ theme: state.theme }) # ✅
// Increment version when the schema changes
version: 1, migrate: (persisted, version) => { ... } # ✅
// Use sessionStorage for temporary data
storage: createJSONStorage(() => sessionStorage) # ✅
// Use skipHydration in SSR applications
skipHydration: true # ✅
// Call rehydrate after mount in SSR
useEffect(() => { store.persist.rehydrate() }, []) # ✅
// Check hasHydrated before rendering persisted values
if (!store.persist.hasHydrated()) return <Skeleton /> # ✅
❌ Don’t Do This:
// Persist the entire state including transient flags
// The modal reopens on every reload # ❌
// Store sensitive data (tokens) in localStorage
// Any script on the page can read it # ❌
// Change the schema without a version and migrate
// The old data is merged into the new shape and produces undefined # ❌
// Assume hydration is synchronous for async storage
// The store is not hydrated at the initial render # ❌
// Use localStorage in SSR without skipHydration
// Server and client renders differ; hydration mismatch # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Modal reopens on reload | isOpen is persisted | Exclude with partialize |
| State not restored | Wrong name or storage engine | Verify the key and storage |
| Hydration mismatch in Next.js | Server renders initial state, client renders stored state | Use skipHydration: true |
| Old data breaks the UI | Schema changed without versioning | Add version and migrate |
Persisted state is undefined | Functions are not stored | Actions come from the code |
| Async storage not ready at render | Hydration happens in a microtask | Check hasHydrated() |
Real-World Examples
1. Basic persistence
persist((set) => ({ count: 0 }), { name: 'counter' })
2. Partial persistence
persist((set) => ({ theme: 'light', temp: 0 }), { name: 'settings', partialize: (s) => ({ theme: s.theme }) })
3. Session storage
persist((set) => ({ draft: '' }), { name: 'draft', storage: createJSONStorage(() => sessionStorage) })
4. Version and migrate
persist((set) => ({ items: [] }), { name: 'cart', version: 1, migrate: (persisted, version) => { ... } })
5. Skip hydration
persist((set) => ({ theme: 'light' }), { name: 'theme', skipHydration: true })
6. Manual rehydrate
useEffect(() => { useStore.persist.rehydrate() }, [])
7. Check hydration
const hydrated = useStore.persist.hasHydrated()
8. Hydration callback
onRehydrateStorage: () => (state) => console.log('hydrated', state)
9. Custom storage
storage: createJSONStorage(() => myCustomStorage)
10. Merge custom
merge: (persisted, current) => deepMerge(current, persisted)
Visual
Persistence Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ WRITE ON SET, READ ON STARTUP │
│ │
│ Store created │
│ │ │
│ ▼ │
│ persist reads localStorage[name] │
│ │ │
│ ├── Entry exists → parse JSON → migrate if version │
│ │ └── merge with initial state │
│ │ │
│ └── Entry missing → use initial state │
│ │ │
│ ▼ │
│ Store is hydrated │
│ │ │
│ ▼ │
│ set({ ... }) called │
│ │ │
│ ▼ │
│ persist writes to localStorage[name] │
│ └── {"state": {...}, "version": 0} │
└──────────────────────────────────────────────────────────────┘
Partialize
┌──────────────────────────────────────────────────────────────┐
│ ONLY THE RETURNED FIELDS ARE STORED │
│ │
│ Full state: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { items: [...], isOpen: true, addItem: fn } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ partialize │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { items: [...] } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ localStorage │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ {"state":{"items":[...]},"version":0} │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ isOpen is not stored; it resets to false on reload. │
└──────────────────────────────────────────────────────────────┘
SSR Hydration Mismatch
┌──────────────────────────────────────────────────────────────┐
│ WITHOUT skipHydration, SERVER AND CLIENT DISAGREE │
│ │
│ Server render: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ theme = 'light' (initial state) │ │
│ │ HTML: <button>light</button> │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Client first render (hydrated): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ theme = 'dark' (from localStorage) │ │
│ │ HTML: <button>dark</button> │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Mismatch → React throws a hydration warning │
│ │
│ WITH skipHydration: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Client first render: theme = 'light' (matches server) │ │
│ │ After mount: rehydrate() → theme = 'dark' │ │
│ │ UI updates; no mismatch │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Middleware import | persist from zustand/middleware |
| Required option | name (storage key) |
| Default storage | localStorage |
| Default serialization | JSON ({"state":...,"version":0}) |
| Partial persistence | partialize: (state) => ({ ... }) |
| Versioning | version + migrate |
| SSR deferral | skipHydration: true |
| Manual hydration | store.persist.rehydrate() |
| Hydration check | store.persist.hasHydrated() |
| Storage helper | createJSONStorage |
Key takeaways:
persiststores state inlocalStorageby default. Thenameoption is the key, and the stored entry is JSON with the state and a version number. Functions are not stored; actions come from the code at startup .partializeselects the fields to persist. Without it, the entire state is stored, including transient UI flags that should reset on reload. Thepartializefunction returns only the data fields that should survive .- Schema changes require
versionandmigrate. Theversionnumber identifies the current shape, and themigratefunction transforms old persisted data into the new shape. Without it, a schema change produces undefined fields or discards the data . - SSR requires
skipHydration. In server-rendered applications, the server renders with the initial state and the client hydrates with the stored state, producing a mismatch.skipHydration: truedefers the read until after mount, and the application callsstore.persist.rehydrate()manually . createJSONStorageselects the storage engine. The default islocalStorage, which persists across sessions.sessionStoragepersists until the tab closes. Custom engines can be provided for IndexedDB or other backends .- Do not persist sensitive data.
localStorageis accessible to any script on the page. Tokens and credentials should not be stored in it; use secure storage or server-side sessions instead .
Remember: The persist middleware turns the store into a durable object. It reads the stored value on creation, merges it with the initial state, and writes the updated value on every set call. The name option identifies the storage entry, and the partialize option controls what is stored. The version and migrate options handle schema changes, and the skipHydration option defers the read in server-rendered applications. Persistence is not automatic in the sense of being safe; it requires the developer to decide which fields should survive and how the schema evolves.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!