20 KiB
name, description, license
| name | description | license |
|---|---|---|
| zustand-state-management | Build type-safe global state in React applications with Zustand. Supports TypeScript, persist middleware, devtools, slices pattern, and Next.js SSR. Use when setting up React state, migrating from Redux/Context API, implementing localStorage persistence, or troubleshooting Next.js hydration errors, TypeScript inference issues, or infinite render loops. | MIT |
Zustand State Management
Status: Production Ready ✅ Last Updated: 2025-10-24 Latest Version: zustand@5.0.8 Dependencies: React 18+, TypeScript 5+
Quick Start (3 Minutes)
1. Install Zustand
npm install zustand
# or
pnpm add zustand
# or
yarn add zustand
Why Zustand?
- Minimal API: Only 1 function to learn (
create) - No boilerplate: No providers, reducers, or actions
- TypeScript-first: Excellent type inference
- Fast: Fine-grained subscriptions prevent unnecessary re-renders
- Flexible: Middleware for persistence, devtools, and more
2. Create Your First Store (TypeScript)
import { create } from 'zustand'
interface BearStore {
bears: number
increase: (by: number) => void
reset: () => void
}
const useBearStore = create<BearStore>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
reset: () => set({ bears: 0 }),
}))
CRITICAL: Notice the double parentheses create<T>()() - this is required for TypeScript with middleware.
3. Use Store in Components
import { useBearStore } from './store'
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here...</h1>
}
function Controls() {
const increase = useBearStore((state) => state.increase)
return <button onClick={() => increase(1)}>Add bear</button>
}
Why this works:
- Components only re-render when their selected state changes
- No Context providers needed
- Selector function extracts specific state slice
The 3-Pattern Setup Process
Pattern 1: Basic Store (JavaScript)
For simple use cases without TypeScript:
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
When to use:
- Prototyping
- Small apps
- No TypeScript in project
Pattern 2: TypeScript Store (Recommended)
For production apps with type safety:
import { create } from 'zustand'
// Define store interface
interface CounterStore {
count: number
increment: () => void
decrement: () => void
}
// Create typed store
const useCounterStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
Key Points:
- Separate interface for state + actions
- Use
create<T>()()syntax (currying for middleware) - Full IDE autocomplete and type checking
Pattern 3: Persistent Store
For state that survives page reloads:
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface UserPreferences {
theme: 'light' | 'dark' | 'system'
language: string
setTheme: (theme: UserPreferences['theme']) => void
setLanguage: (language: string) => void
}
const usePreferencesStore = create<UserPreferences>()(
persist(
(set) => ({
theme: 'system',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'user-preferences', // unique name in localStorage
storage: createJSONStorage(() => localStorage), // optional: defaults to localStorage
},
),
)
Why this matters:
- State automatically saved to localStorage
- Restored on page reload
- Works with sessionStorage too
- Handles serialization automatically
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 shallow 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 5 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:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface StoreWithHydration {
count: number
_hasHydrated: boolean
setHasHydrated: (hydrated: boolean) => void
increase: () => void
}
const useStore = create<StoreWithHydration>()(
persist(
(set) => ({
count: 0,
_hasHydrated: false,
setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }),
increase: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'my-store',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
},
),
)
// In component
function MyComponent() {
const hasHydrated = useStore((state) => state._hasHydrated)
if (!hasHydrated) {
return <div>Loading...</div>
}
// Now safe to render with persisted state
return <ActualContent />
}
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:
// ❌ WRONG - Single parentheses
const useStore = create<MyStore>((set) => ({
// ...
}))
// ✅ CORRECT - Double parentheses
const useStore = create<MyStore>()((set) => ({
// ...
}))
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:
// ✅ CORRECT imports for v5
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
// Verify versions
// zustand@5.0.8 includes createJSONStorage
// zustand@4.x uses different API
// Check your package.json
// "zustand": "^5.0.8"
Issue #4: Infinite Render Loop
Error: Component re-renders infinitely, browser freezes
Source: GitHub Discussions #2642
Why It Happens: Creating new object references in selectors causes Zustand to think state changed.
Prevention:
import { shallow } from 'zustand/shallow'
// ❌ WRONG - Creates new object every time
const { bears, fishes } = useStore((state) => ({
bears: state.bears,
fishes: state.fishes,
}))
// ✅ CORRECT Option 1 - Select primitives separately
const bears = useStore((state) => state.bears)
const fishes = useStore((state) => state.fishes)
// ✅ CORRECT Option 2 - Use shallow for multiple values
const { bears, fishes } = useStore(
(state) => ({ bears: state.bears, fishes: state.fishes }),
shallow,
)
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:
import { create, StateCreator } from 'zustand'
// Define slice types
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fishes: number
addFish: () => void
}
// Create slices with proper types
const createBearSlice: StateCreator<
BearSlice & FishSlice, // Combined store type
[], // Middleware mutators (empty if none)
[], // Chained middleware (empty if none)
BearSlice // This slice's type
> = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
})
const createFishSlice: StateCreator<
BearSlice & FishSlice,
[],
[],
FishSlice
> = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
// Combine slices
const useStore = create<BearSlice & FishSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
Middleware Configuration
Persist Middleware (localStorage)
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface MyStore {
data: string[]
addItem: (item: string) => void
}
const useStore = create<MyStore>()(
persist(
(set) => ({
data: [],
addItem: (item) => set((state) => ({ data: [...state.data, item] })),
}),
{
name: 'my-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ data: state.data }), // Only persist 'data'
},
),
)
Devtools Middleware (Redux DevTools)
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface CounterStore {
count: number
increment: () => void
}
const useStore = create<CounterStore>()(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(state) => ({ count: state.count + 1 }),
undefined,
'counter/increment', // Action name in DevTools
),
}),
{ name: 'CounterStore' }, // Store name in DevTools
),
)
Combining Multiple Middlewares
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
const useStore = create<MyStore>()(
devtools(
persist(
(set) => ({
// store definition
}),
{ name: 'my-storage' },
),
{ name: 'MyStore' },
),
)
Order matters: devtools(persist(...)) shows persist actions in DevTools.
Common Patterns
Pattern: Computed/Derived Values
interface StoreWithComputed {
items: string[]
addItem: (item: string) => void
// Computed in selector, not stored
}
const useStore = create<StoreWithComputed>()((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}))
// Use in component
function ItemCount() {
const count = useStore((state) => state.items.length)
return <div>{count} items</div>
}
Pattern: Async Actions
interface AsyncStore {
data: string | null
isLoading: boolean
error: string | null
fetchData: () => Promise<void>
}
const useAsyncStore = create<AsyncStore>()((set) => ({
data: null,
isLoading: false,
error: null,
fetchData: async () => {
set({ isLoading: true, error: null })
try {
const response = await fetch('/api/data')
const data = await response.text()
set({ data, isLoading: false })
} catch (error) {
set({ error: (error as Error).message, isLoading: false })
}
},
}))
Pattern: Resetting Store
interface ResettableStore {
count: number
name: string
increment: () => void
reset: () => void
}
const initialState = {
count: 0,
name: '',
}
const useStore = create<ResettableStore>()((set) => ({
...initialState,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set(initialState),
}))
Pattern: Selector with Params
interface TodoStore {
todos: Array<{ id: string; text: string; done: boolean }>
addTodo: (text: string) => void
toggleTodo: (id: string) => void
}
const useStore = create<TodoStore>()((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now().toString(), text, done: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
),
})),
}))
// Use with parameter
function Todo({ id }: { id: string }) {
const todo = useStore((state) => state.todos.find((t) => t.id === id))
const toggleTodo = useStore((state) => state.toggleTodo)
if (!todo) return null
return (
<div>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(id)}
/>
{todo.text}
</div>
)
}
Using Bundled Resources
Templates (templates/)
This skill includes 8 ready-to-use template files:
basic-store.ts- Minimal JavaScript store exampletypescript-store.ts- Properly typed TypeScript storepersist-store.ts- localStorage persistence with migrationslices-pattern.ts- Modular store organizationdevtools-store.ts- Redux DevTools integrationnextjs-store.ts- SSR-safe Next.js store with hydrationcomputed-store.ts- Derived state patternsasync-actions-store.ts- Async operations with loading states
Example Usage:
# Copy template to your project
cp ~/.claude/skills/zustand-state-management/templates/typescript-store.ts src/store/
When to use each:
- Use
basic-store.tsfor quick prototypes - Use
typescript-store.tsfor most production apps - Use
persist-store.tswhen state needs to survive page reloads - Use
slices-pattern.tsfor large, complex stores (100+ lines) - Use
nextjs-store.tsfor Next.js projects with SSR
References (references/)
Deep-dive documentation for complex scenarios:
middleware-guide.md- Complete middleware documentation (persist, devtools, immer, custom)typescript-patterns.md- Advanced TypeScript patterns and troubleshootingnextjs-hydration.md- SSR, hydration, and Next.js best practicesmigration-guide.md- Migrating from Redux, Context API, or Zustand v4
When Claude should load these:
- Load
middleware-guide.mdwhen user asks about persistence, devtools, or custom middleware - Load
typescript-patterns.mdwhen encountering complex type inference issues - Load
nextjs-hydration.mdfor Next.js-specific problems - Load
migration-guide.mdwhen migrating from other state management solutions
Scripts (scripts/)
check-versions.sh- Verify Zustand version and compatibility
Usage:
cd your-project/
~/.claude/skills/zustand-state-management/scripts/check-versions.sh
Advanced Topics
Vanilla Store (Without React)
import { createStore } from 'zustand/vanilla'
const store = createStore<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// Subscribe to changes
const unsubscribe = store.subscribe((state) => {
console.log('Count changed:', state.count)
})
// Get current state
console.log(store.getState().count)
// Update state
store.getState().increment()
// Cleanup
unsubscribe()
Custom Middleware
import { StateCreator, StoreMutatorIdentifier } from 'zustand'
type Logger = <T>(
f: StateCreator<T, [], []>,
name?: string,
) => StateCreator<T, [], []>
const logger: Logger = (f, name) => (set, get, store) => {
const loggedSet: typeof set = (...a) => {
set(...(a as Parameters<typeof set>))
console.log(`[${name}]:`, get())
}
return f(loggedSet, get, store)
}
// Use custom middleware
const useStore = create<MyStore>()(
logger((set) => ({
// store definition
}), 'MyStore'),
)
Immer Middleware (Mutable Updates)
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface TodoStore {
todos: Array<{ id: string; text: string }>
addTodo: (text: string) => void
}
const useStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
// Mutate directly with Immer
state.todos.push({ id: Date.now().toString(), text })
}),
})),
)
Dependencies
Required:
zustand@5.0.8- State management libraryreact@18.0.0+- React framework
Optional:
@types/node- For TypeScript path resolutionimmer- For mutable update syntax- Redux DevTools Extension - For devtools middleware
Official Documentation
- Zustand: https://zustand.docs.pmnd.rs/
- GitHub: https://github.com/pmndrs/zustand
- TypeScript Guide: https://zustand.docs.pmnd.rs/guides/typescript
- Slices Pattern: https://github.com/pmndrs/zustand/blob/main/docs/guides/slices-pattern.md
- Context7 Library ID:
/pmndrs/zustand
Package Versions (Verified 2025-10-24)
{
"dependencies": {
"zustand": "^5.0.8",
"react": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
}
}
Compatibility:
- React 18+, React 19 ✅
- TypeScript 5+ ✅
- Next.js 14+, Next.js 15+ ✅
- Vite 5+ ✅
Troubleshooting
Problem: Store updates don't trigger re-renders
Solution: Ensure you're using selector functions, not destructuring: const bears = useStore(state => state.bears) not const { bears } = useStore()
Problem: TypeScript errors with middleware
Solution: Use double parentheses: create<T>()() not create<T>()
Problem: Persist middleware causes hydration error
Solution: Implement _hasHydrated flag pattern (see Issue #1)
Problem: Actions not showing in Redux DevTools
Solution: Pass action name as third parameter to set: set(newState, undefined, 'actionName')
Problem: Store state resets unexpectedly
Solution: Check if using HMR (hot module replacement) - Zustand resets on module reload in development
Complete Setup Checklist
Use this checklist to verify your Zustand setup:
- Installed
zustand@5.0.8or later - Created store with proper TypeScript types
- Used
create<T>()()double parentheses syntax - Tested selector functions in components
- Verified components only re-render when selected state changes
- If using persist: Configured unique storage name
- If using persist: Implemented hydration check for Next.js
- If using devtools: Named actions for debugging
- If using slices: Properly typed
StateCreatorfor each slice - All actions are pure functions
- No direct state mutations
- Store works in production build
Questions? Issues?
- Check references/typescript-patterns.md for TypeScript help
- Check references/nextjs-hydration.md for Next.js issues
- Check references/middleware-guide.md for persist/devtools help
- Official docs: https://zustand.docs.pmnd.rs/
- GitHub issues: https://github.com/pmndrs/zustand/issues