Zustand State Management
Last Updated: 2026-01-21 Latest Version: zustand@5.0.10 (released 2026-01-12) Dependencies: React 18-19, TypeScript 5+
Quick Start
bash1npm install zustand
TypeScript Store (CRITICAL: use create<T>()() double parentheses):
typescript1import { create } from 'zustand' 2 3interface BearStore { 4 bears: number 5 increase: (by: number) => void 6} 7 8const useBearStore = create<BearStore>()((set) => ({ 9 bears: 0, 10 increase: (by) => set((state) => ({ bears: state.bears + by })), 11}))
Use in Components:
tsx1const bears = useBearStore((state) => state.bears) // Only re-renders when bears changes 2const increase = useBearStore((state) => state.increase)
Core Patterns
Basic Store (JavaScript):
javascript1const useStore = create((set) => ({ 2 count: 0, 3 increment: () => set((state) => ({ count: state.count + 1 })), 4}))
TypeScript Store (Recommended):
typescript1interface CounterStore { count: number; increment: () => void } 2const useStore = create<CounterStore>()((set) => ({ 3 count: 0, 4 increment: () => set((state) => ({ count: state.count + 1 })), 5}))
Persistent Store (survives page reloads):
typescript1import { persist, createJSONStorage } from 'zustand/middleware' 2 3const useStore = create<UserPreferences>()( 4 persist( 5 (set) => ({ theme: 'system', setTheme: (theme) => set({ theme }) }), 6 { name: 'user-preferences', storage: createJSONStorage(() => localStorage) }, 7 ), 8)
Critical Rules
Always Do
✅ Use create<T>()() (double parentheses) in TypeScript for middleware compatibility
✅ Define separate interfaces for state and actions
✅ Use selector functions to extract specific state slices
✅ Use set with updater functions for derived state: set((state) => ({ count: state.count + 1 }))
✅ Use unique names for persist middleware storage keys
✅ Handle Next.js hydration with hasHydrated flag pattern
✅ Use useShallow hook for selecting multiple values
✅ Keep actions pure (no side effects except state updates)
Never Do
❌ Use create<T>(...) (single parentheses) in TypeScript - breaks middleware types
❌ Mutate state directly: set((state) => { state.count++; return state }) - use immutable updates
❌ Create new objects in selectors: useStore((state) => ({ a: state.a })) - causes infinite renders
❌ Use same storage name for multiple stores - causes data collisions
❌ Access localStorage during SSR without hydration check
❌ Use Zustand for server state - use TanStack Query instead
❌ Export store instance directly - always export the hook
Known Issues Prevention
This skill prevents 6 documented issues:
Issue #1: Next.js Hydration Mismatch
Error: "Text content does not match server-rendered HTML" or "Hydration failed"
Source:
- DEV Community: Persist middleware in Next.js
- GitHub Discussions #2839
Why It Happens: Persist middleware reads from localStorage on client but not on server, causing state mismatch.
Prevention:
typescript1import { create } from 'zustand' 2import { persist } from 'zustand/middleware' 3 4interface StoreWithHydration { 5 count: number 6 _hasHydrated: boolean 7 setHasHydrated: (hydrated: boolean) => void 8 increase: () => void 9} 10 11const useStore = create<StoreWithHydration>()( 12 persist( 13 (set) => ({ 14 count: 0, 15 _hasHydrated: false, 16 setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), 17 increase: () => set((state) => ({ count: state.count + 1 })), 18 }), 19 { 20 name: 'my-store', 21 onRehydrateStorage: () => (state) => { 22 state?.setHasHydrated(true) 23 }, 24 }, 25 ), 26) 27 28// In component 29function MyComponent() { 30 const hasHydrated = useStore((state) => state._hasHydrated) 31 32 if (!hasHydrated) { 33 return <div>Loading...</div> 34 } 35 36 // Now safe to render with persisted state 37 return <ActualContent /> 38}
Issue #2: TypeScript Double Parentheses Missing
Error: Type inference fails, StateCreator types break with middleware
Source: Official Zustand TypeScript Guide
Why It Happens:
The currying syntax create<T>()() is required for middleware to work with TypeScript inference.
Prevention:
typescript1// ❌ WRONG - Single parentheses 2const useStore = create<MyStore>((set) => ({ 3 // ... 4})) 5 6// ✅ CORRECT - Double parentheses 7const useStore = create<MyStore>()((set) => ({ 8 // ... 9}))
Rule: Always use create<T>()() in TypeScript, even without middleware (future-proof).
Issue #3: Persist Middleware Import Error
Error: "Attempted import error: 'createJSONStorage' is not exported from 'zustand/middleware'"
Source: GitHub Discussion #2839
Why It Happens: Wrong import path or version mismatch between zustand and build tools.
Prevention:
typescript1// ✅ CORRECT imports for v5 2import { create } from 'zustand' 3import { persist, createJSONStorage } from 'zustand/middleware' 4 5// Verify versions 6// zustand@5.0.9 includes createJSONStorage 7// zustand@4.x uses different API 8 9// Check your package.json 10// "zustand": "^5.0.9"
Issue #4: Infinite Render Loop
Error: Component re-renders infinitely, browser freezes
Uncaught Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
Source:
- GitHub Discussions #2642
- Issue #2863
Why It Happens: Creating new object references in selectors causes Zustand to think state changed.
v5 Breaking Change: Zustand v5 made this error MORE explicit compared to v4. In v4, this behavior was "non-ideal" but could go unnoticed. In v5, you'll immediately see the "Maximum update depth exceeded" error.
Prevention:
typescript1import { useShallow } from 'zustand/shallow' 2 3// ❌ WRONG - Creates new object every time 4const { bears, fishes } = useStore((state) => ({ 5 bears: state.bears, 6 fishes: state.fishes, 7})) 8 9// ✅ CORRECT Option 1 - Select primitives separately 10const bears = useStore((state) => state.bears) 11const fishes = useStore((state) => state.fishes) 12 13// ✅ CORRECT Option 2 - Use useShallow hook for multiple values 14const { bears, fishes } = useStore( 15 useShallow((state) => ({ bears: state.bears, fishes: state.fishes })) 16)
Issue #5: Slices Pattern TypeScript Complexity
Error: StateCreator types fail to infer, complex middleware types break
Source: Official Slices Pattern Guide
Why It Happens: Combining multiple slices requires explicit type annotations for middleware compatibility.
Prevention:
typescript1import { create, StateCreator } from 'zustand' 2 3// Define slice types 4interface BearSlice { 5 bears: number 6 addBear: () => void 7} 8 9interface FishSlice { 10 fishes: number 11 addFish: () => void 12} 13 14// Create slices with proper types 15const createBearSlice: StateCreator< 16 BearSlice & FishSlice, // Combined store type 17 [], // Middleware mutators (empty if none) 18 [], // Chained middleware (empty if none) 19 BearSlice // This slice's type 20> = (set) => ({ 21 bears: 0, 22 addBear: () => set((state) => ({ bears: state.bears + 1 })), 23}) 24 25const createFishSlice: StateCreator< 26 BearSlice & FishSlice, 27 [], 28 [], 29 FishSlice 30> = (set) => ({ 31 fishes: 0, 32 addFish: () => set((state) => ({ fishes: state.fishes + 1 })), 33}) 34 35// Combine slices 36const useStore = create<BearSlice & FishSlice>()((...a) => ({ 37 ...createBearSlice(...a), 38 ...createFishSlice(...a), 39}))
Issue #6: Persist Middleware Race Condition (Fixed v5.0.10+)
Error: Inconsistent state during concurrent rehydration attempts
Source:
Why It Happens: In Zustand v5.0.9 and earlier, concurrent calls to rehydrate during persist middleware initialization could cause a race condition where multiple hydration attempts would interfere with each other, leading to inconsistent state.
Prevention: Upgrade to Zustand v5.0.10 or later. No code changes needed - the fix is internal to the persist middleware.
bash1npm install zustand@latest # Ensure v5.0.10+
Note: This was fixed in v5.0.10 (January 2026). If you're using v5.0.9 or earlier and experiencing state inconsistencies with persist middleware, upgrade immediately.
Middleware
Persist (localStorage):
typescript1import { persist, createJSONStorage } from 'zustand/middleware' 2 3const useStore = create<MyStore>()( 4 persist( 5 (set) => ({ data: [], addItem: (item) => set((state) => ({ data: [...state.data, item] })) }), 6 { 7 name: 'my-storage', 8 partialize: (state) => ({ data: state.data }), // Only persist 'data' 9 }, 10 ), 11)
Devtools (Redux DevTools):
typescript1import { devtools } from 'zustand/middleware' 2 3const useStore = create<CounterStore>()( 4 devtools( 5 (set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 }), undefined, 'increment') }), 6 { name: 'CounterStore' }, 7 ), 8)
v4→v5 Migration Note: In Zustand v4, devtools was imported from 'zustand/middleware/devtools'. In v5, use 'zustand/middleware' (as shown above). If you see "Module not found: Can't resolve 'zustand/middleware/devtools'", update your import path.
Combining Middlewares (order matters):
typescript1const useStore = create<MyStore>()(devtools(persist((set) => ({ /* ... */ }), { name: 'storage' }), { name: 'MyStore' }))
Common Patterns
Computed/Derived Values (in selector, not stored):
typescript1const count = useStore((state) => state.items.length) // Computed on read
Async Actions:
typescript1const useAsyncStore = create<AsyncStore>()((set) => ({ 2 data: null, 3 isLoading: false, 4 fetchData: async () => { 5 set({ isLoading: true }) 6 const response = await fetch('/api/data') 7 set({ data: await response.text(), isLoading: false }) 8 }, 9}))
Resetting Store:
typescript1const initialState = { count: 0, name: '' } 2const useStore = create<ResettableStore>()((set) => ({ 3 ...initialState, 4 reset: () => set(initialState), 5}))
Selector with Params:
typescript1const todo = useStore((state) => state.todos.find((t) => t.id === id))
Bundled Resources
Templates: basic-store.ts, typescript-store.ts, persist-store.ts, slices-pattern.ts, devtools-store.ts, nextjs-store.ts, computed-store.ts, async-actions-store.ts
References: middleware-guide.md (persist/devtools/immer/custom), typescript-patterns.md (type inference issues), nextjs-hydration.md (SSR/hydration), migration-guide.md (from Redux/Context/v4)
Scripts: check-versions.sh (version compatibility)
Advanced Topics
Vanilla Store (Without React):
typescript1import { createStore } from 'zustand/vanilla' 2 3const store = createStore<CounterStore>()((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })) })) 4const unsubscribe = store.subscribe((state) => console.log(state.count)) 5store.getState().increment()
Custom Middleware:
typescript1const logger: Logger = (f, name) => (set, get, store) => { 2 const loggedSet: typeof set = (...a) => { set(...a); console.log(`[${name}]:`, get()) } 3 return f(loggedSet, get, store) 4}
Immer Middleware (Mutable Updates):
typescript1import { immer } from 'zustand/middleware/immer' 2 3const useStore = create<TodoStore>()(immer((set) => ({ 4 todos: [], 5 addTodo: (text) => set((state) => { state.todos.push({ id: Date.now().toString(), text }) }), 6})))
v5.0.3→v5.0.4 Migration Note: If upgrading from v5.0.3 to v5.0.4+ and immer middleware stops working, verify you're using the import path shown above (zustand/middleware/immer). Some users reported issues after the v5.0.4 update that were resolved by confirming the correct import.
Experimental SSR Safe Middleware (v5.0.9+):
Status: Experimental (API may change)
Zustand v5.0.9 introduced experimental unstable_ssrSafe middleware for Next.js usage. This provides an alternative approach to the _hasHydrated pattern (see Issue #1).
typescript1import { unstable_ssrSafe } from 'zustand/middleware' 2 3const useStore = create<Store>()( 4 unstable_ssrSafe( 5 persist( 6 (set) => ({ /* state */ }), 7 { name: 'my-store' } 8 ) 9 ) 10)
Recommendation: Continue using the _hasHydrated pattern documented in Issue #1 until this API stabilizes. Monitor Discussion #2740 for updates on when this becomes stable.
Official Documentation
- Zustand: https://zustand.docs.pmnd.rs/
- GitHub: https://github.com/pmndrs/zustand
- TypeScript Guide: https://zustand.docs.pmnd.rs/guides/typescript
- Context7 Library ID:
/pmndrs/zustand