home / skills / ovachiever / droid-tings / zustand-state-management

zustand-state-management skill

/skills/zustand-state-management

This skill helps you implement and troubleshoot Zustand state management in React apps, with TypeScript, persistence, and SSR considerations.

npx playbooks add skill ovachiever/droid-tings --skill zustand-state-management

Review the files below or copy the command above to add this skill to your agents.

Files (19)
SKILL.md
19.7 KB
---
name: zustand-state-management
description: |
  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.
license: MIT
---

# Zustand State Management

**Status**: Production Ready ✅
**Last Updated**: 2025-10-24
**Latest Version**: [email protected]
**Dependencies**: React 18+, TypeScript 5+

---

## Quick Start (3 Minutes)

### 1. Install Zustand

```bash
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)

```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

```tsx
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:

```javascript
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:

```typescript
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:

```typescript
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](https://dev.to/abdulsamad/how-to-use-zustands-persist-middleware-in-nextjs-4lb5)
- GitHub Discussions #2839

**Why It Happens**:
Persist middleware reads from localStorage on client but not on server, causing state mismatch.

**Prevention**:
```typescript
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](https://zustand.docs.pmnd.rs/guides/typescript)

**Why It Happens**:
The currying syntax `create<T>()()` is required for middleware to work with TypeScript inference.

**Prevention**:
```typescript
// ❌ 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**:
```typescript
// ✅ CORRECT imports for v5
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

// Verify versions
// [email protected] includes createJSONStorage
// [email protected] 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**:
```typescript
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](https://github.com/pmndrs/zustand/blob/main/docs/guides/slices-pattern.md)

**Why It Happens**:
Combining multiple slices requires explicit type annotations for middleware compatibility.

**Prevention**:
```typescript
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)

```typescript
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)

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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 example
- `typescript-store.ts` - Properly typed TypeScript store
- `persist-store.ts` - localStorage persistence with migration
- `slices-pattern.ts` - Modular store organization
- `devtools-store.ts` - Redux DevTools integration
- `nextjs-store.ts` - SSR-safe Next.js store with hydration
- `computed-store.ts` - Derived state patterns
- `async-actions-store.ts` - Async operations with loading states

**Example Usage:**
```bash
# Copy template to your project
cp ~/.claude/skills/zustand-state-management/templates/typescript-store.ts src/store/
```

**When to use each:**
- Use `basic-store.ts` for quick prototypes
- Use `typescript-store.ts` for most production apps
- Use `persist-store.ts` when state needs to survive page reloads
- Use `slices-pattern.ts` for large, complex stores (100+ lines)
- Use `nextjs-store.ts` for 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 troubleshooting
- `nextjs-hydration.md` - SSR, hydration, and Next.js best practices
- `migration-guide.md` - Migrating from Redux, Context API, or Zustand v4

**When Claude should load these:**
- Load `middleware-guide.md` when user asks about persistence, devtools, or custom middleware
- Load `typescript-patterns.md` when encountering complex type inference issues
- Load `nextjs-hydration.md` for Next.js-specific problems
- Load `migration-guide.md` when migrating from other state management solutions

### Scripts (scripts/)

- `check-versions.sh` - Verify Zustand version and compatibility

**Usage:**
```bash
cd your-project/
~/.claude/skills/zustand-state-management/scripts/check-versions.sh
```

---

## Advanced Topics

### Vanilla Store (Without React)

```typescript
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

```typescript
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)

```typescript
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**:
- `[email protected]` - State management library
- `[email protected]+` - React framework

**Optional**:
- `@types/node` - For TypeScript path resolution
- `immer` - 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)

```json
{
  "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 `[email protected]` or 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 `StateCreator` for each slice
- [ ] All actions are pure functions
- [ ] No direct state mutations
- [ ] Store works in production build

---

**Questions? Issues?**

1. Check [references/typescript-patterns.md](references/typescript-patterns.md) for TypeScript help
2. Check [references/nextjs-hydration.md](references/nextjs-hydration.md) for Next.js issues
3. Check [references/middleware-guide.md](references/middleware-guide.md) for persist/devtools help
4. Official docs: https://zustand.docs.pmnd.rs/
5. GitHub issues: https://github.com/pmndrs/zustand/issues

Overview

This skill teaches how to build type-safe global state in React apps using Zustand. It covers TypeScript-first patterns, persist and devtools middleware, slices composition, and Next.js SSR hydration strategies. Included templates and examples speed up setup, migrations from Redux/Context, and common troubleshooting.

How this skill works

The skill shows how to create stores with create<T>()() for correct TypeScript inference and how to apply middlewares like persist, devtools, and createJSONStorage. It demonstrates selector usage for fine-grained subscriptions, slice composition for modular stores, and hydration flags to prevent Next.js server/client mismatches. Templates provide ready-to-copy examples for common patterns and edge-case fixes.

When to use it

  • Setting up a new React app and you want minimal, type-safe global state
  • Migrating from Redux or Context API to a lighter, more ergonomic store
  • Persisting UI preferences or small caches to localStorage/sessionStorage
  • Preventing Next.js hydration mismatches caused by client-only persistence
  • Troubleshooting infinite render loops or TypeScript inference failures

Best practices

  • Always use create<T>()() in TypeScript to keep middleware types correct
  • Define separate interfaces for state and actions for clear typing and autocomplete
  • Use selectors to pick primitives or use shallow when selecting multiple values
  • Keep actions pure and use updater functions: set((s) => ({ count: s.count + 1 }))
  • Use unique keys for persist middleware and avoid accessing localStorage during SSR
  • Prefer TanStack Query for server data and use Zustand for client state only

Example use cases

  • A Next.js settings panel that persists theme and language with hydration-safe loading
  • Replacing Redux in a small-to-medium app to reduce boilerplate and improve performance
  • A todo app using slices pattern for todos, filters, and user preferences
  • A component library demo that needs fast, isolated state with devtools tracing
  • An app that caches small API results in localStorage and exposes async fetch actions

FAQ

Why use create<T>()() instead of create<T>() or create<T>(...) ?

The double-currying create<T>()() preserves correct StateCreator types when using middleware; single parentheses break middleware type inference in TypeScript.

How do I avoid hydration mismatch in Next.js with persisted state?

Add a hasHydrated flag in the store, set it onRehydrateStorage, and render a loading fallback until hydration completes to match server HTML.