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:

  1. Read – Retrieves the stored JSON string using storage.getItem.
  2. Migrate – If a version mismatch is detected, runs the user-provided migrate function.
  3. Merge – Combines the persisted slice with the fresh initial state using the optional merge strategy.
  4. Subscribe – Attaches a listener to setState that writes back to storage on every change, applying partialize to 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, and migrate options.
  • Use skipHydration for SSR compatibility, then call store.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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →