Zustand Persistence & Middleware: Making State Stick

  • Home
  • Zustand
  • Zustand Persistence & Middleware: Making State Stick
Front
Back
Right
Left
Top
Bottom
WHY

Why Persistence Matters

Refreshing a page and losing your shopping cart is frustrating. Logging out and losing preferences is annoying. State persistence solves these problems by saving state across sessions.

As ash explains in “Taking Zustand Further,” state persistence is critical for user data, authentication tokens, shopping carts, and preferences. Manual localStorage management quickly becomes unmaintainable.
MIDDLEWARE

Basic Persistence: The persist Middleware

Setup in 60 Seconds​

📘
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface CartState {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  clearCart: () => void
}

const useCartStore = create<CartState>()(
  persist(
    (set) => ({
      items: [],
      addItem: (item) => set((state) => ({
        items: [...state.items, item],
      })),
      removeItem: (id) => set((state) => ({
        items: state.items.filter(i => i.id !== id),
      })),
      clearCart: () => set({ items: [] }),
    }),
    {
      name: 'shopping-cart', // localStorage key
    }
  )
)
What you get
STORAGE

Storage Options: localStorage vs sessionStorage vs Custom

localStorage (Default)

📘
const useStore = create()(
  persist(
    (set) => ({ /* state */ }),
    { name: 'my-storage' } // Uses localStorage by default
  )
)

sessionStorage

📘
const useStore = create()(
  persist(
    (set) => ({ /* state */ }),
    {
      name: 'session-data',
      storage: createJSONStorage(() => sessionStorage),
    }
  )
)
PARTIAL
Don't Save Everything

Partial Persistence

Not all state should persist. Temporary UI state like loading indicators shouldn’t clutter storage. The Zustand persist documentation demonstrates `partialize` to selectively persist only relevant state portions.

📘
interface AppState {
  user: User | null
  token: string | null
  theme: 'light' | 'dark'
  isLoading: boolean // Don't persist
  modalOpen: boolean // Don't persist
  setUser: (user: User) => void
  setTheme: (theme: 'light' | 'dark') => void
}

const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      theme: 'light',
      isLoading: false,
      modalOpen: false,
      setUser: (user) => set({ user }),
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'app-storage',
      partialize: (state) => ({
        user: state.user,
        token: state.token,
        theme: state.theme,
        // Exclude isLoading and modalOpen
      }),
    }
  )
)
STORAGE
Custom Storage

IndexedDB, Cookies, or Anywhere

IndexedDB Example

📘
import { get, set, del } from 'idb-keyval'
import { StateStorage } from 'zustand/middleware'

const idbStorage: StateStorage = {
  getItem: async (name: string): Promise<string | null> => {
    return (await get(name)) || null
  },
  setItem: async (name: string, value: string): Promise<void> => {
    await set(name, value)
  },
  removeItem: async (name: string): Promise<void> => {
    await del(name)
  },
}

const useStore = create()(
  persist(
    (set) => ({ /* state */ }),
    {
      name: 'idb-storage',
      storage: createJSONStorage(() => idbStorage),
    }
  )
)
Warning
Async storage has a cost. The Zustand documentation explains that async storage causes hydration to complete in a microtask, meaning the store initially contains default state.

Handling Complex Data Types

JSON.stringify can’t handle Maps, Sets, Dates, or RegExp. Solution: custom serialization. The Zustand GitHub documentation shows using superjson or similar libraries (serialize-javascript, devalue) for complex types.

📘
import superjson from 'superjson'
import { PersistStorage } from 'zustand/middleware'

interface ComplexState {
  userMap: Map<string, User>
  tags: Set<string>
  createdAt: Date
  pattern: RegExp
}

const storage: PersistStorage<ComplexState> = {
  getItem: (name) => {
    const str = localStorage.getItem(name)
    if (!str) return null
    return superjson.parse(str)
  },
  setItem: (name, value) => {
    localStorage.setItem(name, superjson.stringify(value))
  },
  removeItem: (name) => localStorage.removeItem(name),
}

const useComplexStore = create<ComplexState>()(
  persist(
    (set) => ({
      userMap: new Map(),
      tags: new Set(),
      createdAt: new Date(),
      pattern: new RegExp(''),
      // ... actions
    }),
    {
      name: 'complex-storage',
      storage,
    }
  )
)
MIGRATIONS
Handling Schema Changes

Migration

When you update your state structure, old persisted data can break your app. Migrations solve this:
📘
interface StateV1 {
  count: number
}

interface StateV2 {
  count: number
  multiplier: number // New field
}

const useStore = create<StateV2>()(
  persist(
    (set) => ({
      count: 0,
      multiplier: 1,
      increment: () => set((state) => ({
        count: state.count + state.multiplier
      })),
    }),
    {
      name: 'counter-storage',
      version: 2, // Increment when schema changes
      migrate: (persistedState: any, version: number) => {
        if (version === 1) {
          // Migrate from v1 to v2
          return {
            ...persistedState,
            multiplier: 1, // Add missing field
          }
        }
        return persistedState
      },
    }
  )
)
COMBINING
The Right Order

Combining Middleware

Middleware order affects behavior. General rule: devtools → persist → immer

📘
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

const useStore = create<State>()(
  devtools(           // 1. Outermost: debugging
    persist(          // 2. Middle: persistence
      immer(          // 3. Innermost: mutations
        (set) => ({
          user: { profile: { name: '' } },
          updateName: (name) => set((state) => {
            state.user.profile.name = name // Immer magic
          }),
        })
      ),
      { name: 'user-storage' }
    ),
    { name: 'UserStore' }
  )
)
Why this order?
PITFALLS

Common Pitfalls

Issue Problem Solution
Persisting Functions Don't try to persist actions. They can't be serialized. Use partialize to exclude them.
Large State Objects localStorage has ~5-10MB limit. Use IndexedDB for large data, or selectively persist.
Sensitive Data Never persist passwords or tokens in localStorage (XSS vulnerable). Use httpOnly cookies for sensitive auth data.

Data that doesn't persist is data that doesn't matter.

Werner Vogels, CTO of Amazon

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

Not directly with persist middleware. You'd need custom logic. Consider why you need both—usually one suffices.

Access `useStore.persist.clearStorage()`. Example: `useAuthStore.persist.clearStorage()` on logout.

Yes! Use AsyncStorage instead of localStorage: `storage: createJSONStorage(() => AsyncStorage)`.

The persist middleware will fail silently. Monitor storage usage and implement error handling via `onRehydrateStorage` callback.

Yes, via `useStore.persist.rehydrate()`. Useful for testing or manual sync scenarios.

Comments are closed