Why Persistence Matters
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
- Automatic save to localStorage on every state change
- Automatic load on app startup
- Zero manual `localStorage.setItem()` calls
Storage Options: localStorage vs sessionStorage vs Custom
localStorage (Default)
const useStore = create()(
persist(
(set) => ({ /* state */ }),
{ name: 'my-storage' } // Uses localStorage by default
)
)
- Persists: Across sessions (until manually cleared)
- Use for: User preferences, auth tokens, long-term data
sessionStorage
const useStore = create()(
persist(
(set) => ({ /* state */ }),
{
name: 'session-data',
storage: createJSONStorage(() => sessionStorage),
}
)
)
- Persists: Only during the session (cleared on tab close)
- Use for: Temporary UI state, form data in multi-step flows
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
}),
}
)
)
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
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,
}
)
)
Migration
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 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?
- DevTools should see all changes (outermost)
- Persist should work with final state after immer processes it
- Immer transforms mutations to immutable updates (innermost)
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.
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!
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