| | |

Zustand 07 ⚛️ Middleware Persist — Persisting store data to localStorage

⏱️ Estimated read: 13 min | |

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

OptionRequiredPurpose
nameYeslocalStorage key
storageNoStorage engine (default: localStorage)
partializeNoSelect fields to persist
versionNoSchema version number
migrateNoTransform old persisted state
mergeNoCustom merge of persisted and initial state
skipHydrationNoDefer hydration until manual call
onRehydrateStorageNoCallback before/after hydration

Storage Engines

EnginePersistenceUse Case
localStorageAcross sessionsPreferences, carts
sessionStorageUntil tab closesDrafts, temporary state
CustomDepends on implementationIndexedDB, URL params

Hydration Methods

MethodPurpose
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

PitfallWhy It HappensFix
Modal reopens on reloadisOpen is persistedExclude with partialize
State not restoredWrong name or storage engineVerify the key and storage
Hydration mismatch in Next.jsServer renders initial state, client renders stored stateUse skipHydration: true
Old data breaks the UISchema changed without versioningAdd version and migrate
Persisted state is undefinedFunctions are not storedActions come from the code
Async storage not ready at renderHydration happens in a microtaskCheck 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

ItemValue
Middleware importpersist from zustand/middleware
Required optionname (storage key)
Default storagelocalStorage
Default serializationJSON ({"state":...,"version":0})
Partial persistencepartialize: (state) => ({ ... })
Versioningversion + migrate
SSR deferralskipHydration: true
Manual hydrationstore.persist.rehydrate()
Hydration checkstore.persist.hasHydrated()
Storage helpercreateJSONStorage

Key takeaways:

  • persist stores state in localStorage by default. The name option 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 .
  • partialize selects the fields to persist. Without it, the entire state is stored, including transient UI flags that should reset on reload. The partialize function returns only the data fields that should survive .
  • Schema changes require version and migrate. The version number identifies the current shape, and the migrate function 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: true defers the read until after mount, and the application calls store.persist.rehydrate() manually .
  • createJSONStorage selects the storage engine. The default is localStorage, which persists across sessions. sessionStorage persists until the tab closes. Custom engines can be provided for IndexedDB or other backends .
  • Do not persist sensitive data. localStorage is 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.


Was this helpful?


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!