---
title: "Zustand Patterns"
description: "Reference for Zustand 5.x state management including slices, middleware, Immer, useShallow, persistence, selectors, and devtools integration. Documents 7 core patterns with TypeScript examples and anti-patterns. Use when building React state management with Zustand instead of Redux."
canonical: "https://orchestkit.yonyon.ai/docs/reference/skills/zustand-patterns"
---

# Zustand Patterns

Reference for Zustand 5.x state management including slices, middleware, Immer, useShallow, persistence, selectors, and devtools integration. Documents 7 core patterns with TypeScript examples and anti-patterns. Use when building React state management with Zustand instead of Redux.

<span className="badge badge-gray">Reference</span> <span className="badge badge-green">low</span>

> **Not directly invocable** — no slash command and no model auto-selection. An agent loads it explicitly via `Read()`.

<ContextualSkillSidebar slug="zustand-patterns" />

> **Zustand Patterns** Reference for Zustand 5.x state management including slices, middleware, Immer, useShallow, persistence, selectors, and devtools integration. Documents 7 core patterns with TypeScript examples and anti-patterns. Use when building React state management with Zustand instead of Redux.


# Zustand Patterns

Modern state management with Zustand 5.x - lightweight, TypeScript-first, no boilerplate.

## Overview

- Global state without Redux complexity
- Shared state across components without prop drilling
- Persisted state with localStorage/sessionStorage
- Computed/derived state with selectors
- State that needs middleware (logging, devtools, persistence)

## Upstream coverage (do not restate)

Zustand's own docs are the source for the mechanics. This skill carries only the OrchestKit delta
on top of them, in `references/ork-delta.md`, the `rules/` files, and the checklist.

| Topic | First-party source |
|-------|--------------------|
| Basic store, `create&lt;State&gt;()()`, actions, async actions | https://github.com/pmndrs/zustand/blob/main/docs/reference/apis/create.md |
| Slices pattern (splitting and combining stores) | https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/slices-pattern.md |
| Typing slices and middleware mutator tuples | https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/advanced-typescript.md |
| Immer middleware (draft mutations) | https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/immer.md |
| persist: `partialize`, `version`, `migrate`, `onRehydrateStorage`, storage adapters | https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/persist.md |
| devtools: action names, `enabled`, `serialize`, `trace` | https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/devtools.md |
| `subscribeWithSelector` and non-React subscriptions | https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/subscribe-with-selector.md |
| Selectors and `useShallow` re-render control | https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/prevent-rerenders-with-use-shallow.md |
| v4 to v5 migration (`createWithEqualityFn`, React 18 floor) | https://github.com/pmndrs/zustand/blob/main/docs/reference/migrations/migrating-to-v5.md |
| SSR and hydration | https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/ssr-and-hydration.md |
| Store testing and reset | https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/testing.md |
| Server-state ownership (use TanStack Query, not Zustand) | https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state |

## Quick Reference

```typescript
// ✅ Create typed store with double-call pattern
const useStore = create<State>()((set, get) => ({ ... }));

// ✅ Use selectors for all state access
const count = useStore((s) => s.count);

// ✅ Use useShallow for multiple values (Zustand 5.x)
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));

// ✅ Middleware order: immer → subscribeWithSelector → devtools → persist
create(persist(devtools(immer((set) => ({ ... })))))

// ❌ Never destructure entire store
const store = useStore(); // Re-renders on ANY change

// ❌ Never store server state (use TanStack Query instead)
const useStore = create((set) => ({ users: [], fetchUsers: async () => ... }));
```

## Key Decisions

| Decision | Option A | Option B | Recommendation |
|----------|----------|----------|----------------|
| State structure | Single store | Multiple stores | **Slices in single store** - easier cross-slice access |
| Nested updates | Spread operator | Immer middleware | **Immer** for deeply nested state (3+ levels) |
| Persistence | Manual localStorage | persist middleware | **persist middleware** with partialize |
| Multiple values | Multiple selectors | useShallow | **useShallow** for 2-5 related values |
| Server state | Zustand | TanStack Query | **TanStack Query** - Zustand for client-only state |
| DevTools | Always on | Conditional | **Conditional** - `enabled: process.env.NODE_ENV === 'development'` |

## Anti-Patterns & Integration

Forbidden patterns (store destructuring, derived state, server state, direct mutation) and React Query integration guidance.

Load Read("$\{CLAUDE_PLUGIN_ROOT\}/skills/zustand-patterns/references/anti-patterns-and-integration.md") for anti-pattern examples and TanStack Query separation patterns.

## Related Skills

- `react-server-components-framework` - RSC hydration considerations with Zustand
- Server state: https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state
- Form state: https://react-hook-form.com/docs/useform

## Capability Details

### store-creation
**Keywords**: zustand, create, store, typescript, state
**Solves**: Setting up type-safe Zustand stores with proper TypeScript inference

### slices-pattern
**Keywords**: slices, modular, split, combine, StateCreator
**Solves**: Organizing large stores into maintainable, domain-specific slices

### middleware-stack
**Keywords**: immer, persist, devtools, middleware, compose
**Solves**: Combining middleware in correct order for immutability, persistence, and debugging

### selector-optimization
**Keywords**: selector, useShallow, re-render, performance, memoization
**Solves**: Preventing unnecessary re-renders with proper selector patterns

### persistence-migration
**Keywords**: persist, localStorage, sessionStorage, migrate, version
**Solves**: Persisting state with schema migrations between versions

## References

Load on demand with `Read("$\{CLAUDE_PLUGIN_ROOT\}/skills/zustand-patterns/references/&lt;file&gt;")`:

| File | Content |
|------|---------|
| `ork-delta.md` | OrchestKit-specific rules: the corrected `zustand/shallow` label, the v5 floor, secret handling, graded slice typing |
| `anti-patterns-and-integration.md` | Forbidden patterns and React Query integration |

Other resources:
- Load: `Read("$\{CLAUDE_PLUGIN_ROOT\}/skills/zustand-patterns/scripts/store-template.ts")` - Production-ready store template
- Load: `Read("$\{CLAUDE_PLUGIN_ROOT\}/skills/zustand-patterns/checklists/zustand-checklist.md")` - Implementation checklist


---

## Rules (3)

### Nest Zustand middleware in the correct order to prevent devtools and persist failures — CRITICAL


## Zustand: Middleware Order

Zustand middleware wraps from inside out. The innermost middleware executes first, and the outermost middleware executes last. Getting this order wrong silently breaks persistence, devtools recording, and immutable updates.

**Incorrect:**
```typescript
// WRONG: immer outermost — draft mutations leak to devtools and persist
const useStore = create<AppState>()(
  immer(devtools(persist((set) => ({
    count: 0,
    increment: () => set((state) => { state.count += 1; }),
  }), { name: 'app-storage' }), { name: 'AppStore' }))
);

// WRONG: devtools inside persist — devtools won't see persist rehydration
devtools(persist(immer((set) => ({ /* ... */ })), { name: 'storage' }), { name: 'Store' });
```

**Correct:**
```typescript
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type {} from '@redux-devtools/extension';

// Correct order: persist > devtools > subscribeWithSelector > immer
const useStore = create<AppState>()(
  persist(
    devtools(
      subscribeWithSelector(
        immer((set) => ({
          count: 0,
          increment: () =>
            set(
              (state) => { state.count += 1; },
              undefined,
              'counter/increment'
            ),
        }))
      ),
      { name: 'AppStore', enabled: process.env.NODE_ENV === 'development' }
    ),
    {
      name: 'app-storage',
      partialize: (state) => ({ count: state.count }),
    }
  )
);
```

**Key rules:**
- **Immer** is always innermost -- transforms draft mutations into immutable updates first
- **subscribeWithSelector** wraps immer -- needs transformed (immutable) state for granular subscriptions
- **devtools** wraps subscribeWithSelector -- records actions after immer transforms them
- **persist** is always outermost -- serializes the final, fully transformed state to storage
- When using a subset, preserve relative order (e.g., `devtools(immer(...))` not `immer(devtools(...))`)

Reference: upstream middleware docs, https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/persist.md and https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/immer.md


### Avoid Zustand middleware pitfalls that cause silent reactivity breaks and hydration failures — HIGH


## Zustand: Middleware Pitfalls

Four common middleware mistakes that cause silent bugs in Zustand stores.

### Pitfall 1: Mutating State Without Immer

**Incorrect:**
```typescript
set((state) => { state.items.push(item); return state; }); // Mutates in place, no re-render
```

**Correct:**
```typescript
set((state) => ({ items: [...state.items, item] }));           // Immutable update
immer((set) => ({ addItem: (item) => set((s) => { s.items.push(item); }) })) // With immer
```

### Pitfall 2: Duplicate Middleware / Wrong Nesting

**Incorrect:**
```typescript
persist(persist((set) => ({ /* ... */ }), { name: 'a' }), { name: 'b' }) // Double-wrap
```

**Correct:**
```typescript
persist((set) => ({ /* ... */ }), { name: 'app-storage', partialize: (s) => ({ theme: s.theme }) })
```

### Pitfall 3: Missing DevTools Type Import

**Incorrect:**
```typescript
import { devtools } from 'zustand/middleware'; // TS errors — types not augmented
```

**Correct:**
```typescript
import { devtools } from 'zustand/middleware';
import type {} from '@redux-devtools/extension'; // Required type augmentation
```

### Pitfall 4: Missing Persist Version Migrations

**Incorrect:**
```typescript
persist((set) => ({ theme: 'light', fontSize: 14 }), { name: 'settings', version: 2 })
// Was version 1 — no migrate function, old state silently dropped
```

**Correct:**
```typescript
persist((set) => ({ theme: 'light', fontSize: 14 }), {
  name: 'settings',
  version: 2,
  migrate: (persisted: unknown, version: number) => {
    const state = persisted as Record<string, unknown>;
    if (version === 1) return { ...state, fontSize: 14 }; // v1->v2: added fontSize
    return state;
  },
})
```

**Key rules:**
- Never use mutable methods (`push`, `splice`, property assignment) in `set()` without `immer` middleware
- Never double-wrap the same middleware -- each should appear exactly once
- Always `import type \{\} from '@redux-devtools/extension'` when using `devtools`
- Always provide a `migrate` function when bumping persist `version`

Reference: `references/ork-delta.md` (secret handling) and https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/persist.md (version and migrate)


### Use the Zustand slice pattern to keep stores maintainable and avoid merge conflicts — HIGH


## Zustand: Slice Pattern

Split large stores into typed slices using `StateCreator`. Each slice owns a domain of state and actions, combined into a single store at creation time.

**Incorrect:**
```typescript
// Monolithic store — all domains in one create() call
const useStore = create<AllState>()((set, get) => ({
  user: null, token: null,
  login: async (creds) => { /* ... */ },
  logout: () => set({ user: null, token: null }),
  items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  sidebarOpen: false, theme: 'light',
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  // ... 20 more fields — unmaintainable
}));
```

**Correct:**
```typescript
import { create, StateCreator } from 'zustand';
import { immer } from 'zustand/middleware/immer';

type AppStore = AuthSlice & CartSlice & UISlice;

// --- Auth Slice (store/auth-slice.ts) ---
interface AuthSlice { user: User | null; login: (creds: Credentials) => Promise<void>; logout: () => void; }

const createAuthSlice: StateCreator<
  AppStore, [['zustand/immer', never]], [], AuthSlice
> = (set) => ({
  user: null,
  login: async (creds) => { set({ user: await api.login(creds) }, undefined, 'auth/login'); },
  logout: () => set((s) => { s.user = null; }, undefined, 'auth/logout'),
});

// --- Cart Slice (cross-slice access via get()) ---
interface CartSlice { items: CartItem[]; addItem: (item: CartItem) => void; }

const createCartSlice: StateCreator<
  AppStore, [['zustand/immer', never]], [], CartSlice
> = (set, get) => ({
  items: [],
  addItem: (item) => {
    if (!get().user) return; // Cross-slice access via get()
    set((s) => { s.items.push(item); }, undefined, 'cart/addItem');
  },
});

// --- Combined Store ---
const useStore = create<AppStore>()(
  immer((...a) => ({
    ...createAuthSlice(...a),
    ...createCartSlice(...a),
    ...createUISlice(...a),
  }))
);
```

**Key rules:**
- Type each slice as `StateCreator&lt;CombinedStore, MiddlewareMutators, [], SliceInterface&gt;` for full store type inference
- Combine with spread: `create&lt;Store&gt;()((...a) => (\{ ...createSliceA(...a), ...createSliceB(...a) \}))` -- `...a` forwards `set`, `get`, `store`
- Access other slices via `get()` inside actions, never by importing state directly -- avoids circular dependencies
- Keep each slice in its own file, export only the creator function and interface
- Declare middleware mutator types in the `StateCreator` generic so TypeScript knows available features

Reference: `references/ork-delta.md` (mutator tuple is graded here) and https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/advanced-typescript.md



---

## References (2)

### Anti Patterns And Integration

# Zustand Anti-Patterns & React Query Integration

## Anti-Patterns (FORBIDDEN)

```typescript
// FORBIDDEN: Destructuring entire store
const { count, increment } = useStore(); // Re-renders on ANY state change

// FORBIDDEN: Storing derived/computed state
const useStore = create((set) => ({
  items: [],
  total: 0, // Will get out of sync!
}));

// FORBIDDEN: Storing server state
const useStore = create((set) => ({
  users: [], // Use TanStack Query instead
  fetchUsers: async () => { ... },
}));

// FORBIDDEN: Mutating state without Immer
set((state) => {
  state.items.push(item); // Breaks reactivity!
  return state;
});

// FORBIDDEN: Using deprecated shallow import
import { shallow } from 'zustand/shallow'; // Use useShallow from zustand/react/shallow
```

## Integration with React Query

```typescript
// Zustand for CLIENT state (UI, preferences, local-only)
const useUIStore = create<UIState>()((set) => ({
  sidebarOpen: false,
  theme: 'light',
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));

// TanStack Query for SERVER state (API data)
function Dashboard() {
  const sidebarOpen = useUIStore((s) => s.sidebarOpen);
  const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  // Zustand: UI state | TanStack Query: server data
}
```


### Ork Delta

# OrchestKit delta for Zustand

Everything else about Zustand lives upstream (see the "Upstream coverage" table in
`SKILL.md`). This file holds only what this repo learned or decided on top of it.

## Do not re-label `zustand/shallow` as a deprecated import path

Why: an earlier revision of `checklists/zustand-checklist.md` told readers that
`zustand/shallow` was deprecated in favour of `zustand/react/shallow`. PR #2143 (merge commit
e4854df83) corrected it as a "Zustand backwards-label fix" during the 2026-05-31 library-currency
audit. Both module paths export `useShallow` in v5; the actual v5 removal is the equality-function
argument to `create()`, which moved to `createWithEqualityFn` in `zustand/traditional`. Repeat the
old label and this skill regresses to the state the audit already fixed.
Upstream: https://github.com/pmndrs/zustand/blob/main/docs/reference/migrations/migrating-to-v5.md

## Keep the `targets: zustand >= 5.0.0` floor in frontmatter, never in prose

Why: house decision from the repo skill-authoring rule under `.claude` ("a requirement claim goes
in frontmatter, not prose"), and the floor is load-bearing here: every selector instruction in this
skill assumes the v5 surface (the `useShallow` hook, React 18 minimum, no equality-fn overload on
`create()`). A v4 reader following v5 selector guidance gets silent stale renders. Distilled from
the retired core-patterns.md; no traced incident.
Upstream: https://github.com/pmndrs/zustand/blob/main/docs/reference/migrations/migrating-to-v5.md

## Keep secrets out of persisted state and out of the devtools timeline

Why: house security convention, still enforced by the Security section of
`checklists/zustand-checklist.md` ("No sensitive data in persisted state", "DevTools sanitizes
sensitive fields"). The two worked examples that carried it (a devtools `serialize.replacer`
redacting `password` and `token`, and a form-wizard `partialize` that deliberately omits payment
fields) lived only in the deleted files, so the convention is restated here rather than lost.
Distilled from the retired middleware-composition.md and zustand-examples.md;
no traced incident.
Upstream: https://github.com/pmndrs/zustand/blob/main/docs/reference/middlewares/persist.md

## Type each slice with the store's middleware mutator tuple

Why: `rules/zustand-slice-pattern.md` and the `zustand-slice-pattern` case in `test-cases.json`
both grade on `StateCreator&lt;Store, [['zustand/immer', never]], [], Slice&gt;`, so the mutator tuple is
a graded house convention here, not an optional stylistic choice. Its only worked example lived in
the retired middleware-composition.md, so the pointer is recorded here instead. Distilled from the
retired middleware-composition.md; no traced incident.
Upstream: https://github.com/pmndrs/zustand/blob/main/docs/learn/guides/advanced-typescript.md



---

## Checklists (1)

### Zustand Checklist

# Zustand Implementation Checklist

Comprehensive checklist for production-ready Zustand stores.

## Store Setup

### TypeScript Configuration
- [ ] Store interface defined with all state and actions
- [ ] `create&lt;State&gt;()()` double-call pattern used for type inference
- [ ] Action return types are `void` (mutations via `set()`)
- [ ] `type \{\} from '@redux-devtools/extension'` imported for devtools typing

### Store Structure
- [ ] Single store with slices (not multiple separate stores)
- [ ] Each slice has single responsibility (auth, cart, ui, etc.)
- [ ] Initial state extracted to const for reset functionality
- [ ] Reset action implemented for testing/logout

### Middleware Stack
- [ ] Middleware applied in correct order: `persist(devtools(subscribeWithSelector(immer(...))))`
- [ ] Immer used if nested state updates needed (3+ levels deep)
- [ ] DevTools enabled for development only
- [ ] DevTools has meaningful store name

## Selectors

### Basic Selectors
- [ ] Every state access uses a selector
- [ ] No full-store destructuring: `const \{ x, y \} = useStore()` ❌
- [ ] Selectors are granular (one value per selector when possible)

### Multi-Value Selectors
- [ ] `useShallow` used for selecting multiple related values
- [ ] Import `useShallow` from `zustand/shallow` (the v5 migration-guide path)
- [ ] Deprecated: passing an equality fn to `create()` — migrate to `createWithEqualityFn` from `zustand/traditional`

### Computed Values
- [ ] Derived state computed in selectors, not stored
- [ ] Expensive computations memoized with `useMemo` if needed

### Action Selectors
- [ ] Action selectors exported for stable references
- [ ] Actions grouped by domain: `useAuthActions()`, `useCartActions()`

## Persistence

### Configuration
- [ ] `partialize` used to persist only necessary fields
- [ ] Ephemeral state excluded (loading, errors, UI toggles)
- [ ] Storage key is unique and descriptive

### Migrations
- [ ] `version` field set (start at 1)
- [ ] `migrate` function handles all version transitions
- [ ] Migrations are tested
- [ ] `onRehydrateStorage` handles errors gracefully

### Storage Selection
- [ ] localStorage for cross-tab persistence
- [ ] sessionStorage for tab-scoped persistence
- [ ] IndexedDB for large data (via idb-keyval)

## DevTools

### Configuration
- [ ] DevTools disabled in production: `enabled: process.env.NODE_ENV === 'development'`
- [ ] Store has descriptive name
- [ ] Sensitive data sanitized in serialize config

### Action Naming
- [ ] All `set()` calls include action name: `set(fn, undefined, 'domain/action')`
- [ ] Action names follow convention: `domain/action` or `domain/sub/action`
- [ ] No anonymous actions in devtools timeline

## Performance

### Re-render Prevention
- [ ] Components only subscribe to needed state
- [ ] Large lists use virtualization
- [ ] Expensive selectors memoized

### Bundle Size
- [ ] Tree-shaking works (check bundle analyzer)
- [ ] Unused middleware not imported

## Testing

### Test Setup
- [ ] Store can be reset between tests
- [ ] `getState()` used for assertions
- [ ] `setState()` used for test setup

### Test Coverage
- [ ] All actions tested
- [ ] Selector outputs verified
- [ ] Persistence/rehydration tested
- [ ] Migrations tested with old state snapshots

## Integration

### React Query Separation
- [ ] Server state in React Query (API data)
- [ ] Client state in Zustand (UI, preferences)
- [ ] No API calls in Zustand actions (use React Query mutations)

### SSR/RSC Considerations
- [ ] Hydration mismatch handled
- [ ] `useStore` only called in client components
- [ ] Initial state matches server render

## Code Organization

### File Structure
```
stores/
├── index.ts           # Re-exports
├── app-store.ts       # Main store with all slices
├── slices/
│   ├── auth-slice.ts
│   ├── cart-slice.ts
│   └── ui-slice.ts
├── selectors/
│   └── index.ts       # All selector exports
└── types.ts           # Shared types
```

### Naming Conventions
- [ ] Store hook: `useAppStore`, `useAuthStore`
- [ ] Selectors: `useUser`, `useCartItems`, `useTheme`
- [ ] Action selectors: `useAuthActions`, `useCartActions`
- [ ] Slices: `createAuthSlice`, `createCartSlice`

## Security

- [ ] No sensitive data in persisted state (tokens, passwords)
- [ ] DevTools sanitizes sensitive fields
- [ ] Auth tokens stored in memory-only slice or secure storage

## Documentation

- [ ] Store interface documented with JSDoc
- [ ] Complex actions have usage examples
- [ ] Migration history documented
- [ ] README explains store architecture
