Skip to main content
OrchestKit v6.7.1 — 67 skills, 38 agents, 77 hooks with Opus 4.6 support
OrchestKit
Skills

Zustand Patterns

Zustand 5.x state management with slices, middleware, Immer, useShallow, and persistence patterns for React applications. Use when building state management with Zustand.

Reference low

Primary Agent: frontend-ui-developer

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)

Core Patterns

1. Basic Store with TypeScript

import { create } from 'zustand';

interface BearState {
  bears: number;
  increase: (by: number) => void;
  reset: () => void;
}

const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
  reset: () => set({ bears: 0 }),
}));

2. Slices Pattern (Modular Stores)

import { create, StateCreator } from 'zustand';

// Auth slice
interface AuthSlice {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}

const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = (set) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
});

// Cart slice
interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  clearCart: () => void;
}

const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  clearCart: () => set({ items: [] }),
});

// Combined store
const useStore = create<AuthSlice & CartSlice>()((...a) => ({
  ...createAuthSlice(...a),
  ...createCartSlice(...a),
}));

3. Immer Middleware (Immutable Updates)

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  updateNested: (id: string, subtaskId: string, done: boolean) => void;
}

const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    addTodo: (text) =>
      set((state) => {
        state.todos.push({ id: crypto.randomUUID(), text, done: false });
      }),
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done;
      }),
    updateNested: (id, subtaskId, done) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        const subtask = todo?.subtasks?.find((s) => s.id === subtaskId);
        if (subtask) subtask.done = done;
      }),
  }))
);

4. Persist Middleware

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface SettingsState {
  theme: 'light' | 'dark';
  language: string;
  setTheme: (theme: 'light' | 'dark') => void;
}

const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme }), // Only persist theme
      version: 1,
      migrate: (persisted, version) => {
        if (version === 0) {
          // Migration logic
        }
        return persisted as SettingsState;
      },
    }
  )
);

5. Selectors (Prevent Re-renders)

// ❌ BAD: Re-renders on ANY state change
const { bears, fish } = useBearStore();

// ✅ GOOD: Only re-renders when bears changes
const bears = useBearStore((state) => state.bears);

// ✅ GOOD: Shallow comparison for objects (Zustand 5.x)
import { useShallow } from 'zustand/react/shallow';

const { bears, fish } = useBearStore(
  useShallow((state) => ({ bears: state.bears, fish: state.fish }))
);

// ✅ GOOD: Computed/derived state via selector
const totalAnimals = useBearStore((state) => state.bears + state.fish);

// ❌ BAD: Storing computed state
const useStore = create((set) => ({
  items: [],
  total: 0, // Don't store derived values!
  addItem: (item) => set((s) => ({
    items: [...s.items, item],
    total: s.total + item.price, // Sync issues!
  })),
}));

// ✅ GOOD: Compute in selector
const total = useStore((s) => s.items.reduce((sum, i) => sum + i.price, 0));

6. Async Actions

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (id: string) => Promise<void>;
}

const useUserStore = create<UserState>()((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null });
    try {
      const user = await api.getUser(id);
      set({ user, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

7. DevTools Integration

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useStore = create<State>()(
  devtools(
    (set) => ({
      // ... state and actions
    }),
    { name: 'MyStore', enabled: process.env.NODE_ENV === 'development' }
  )
);

Quick Reference

// ✅ 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

DecisionOption AOption BRecommendation
State structureSingle storeMultiple storesSlices in single store - easier cross-slice access
Nested updatesSpread operatorImmer middlewareImmer for deeply nested state (3+ levels)
PersistenceManual localStoragepersist middlewarepersist middleware with partialize
Multiple valuesMultiple selectorsuseShallowuseShallow for 2-5 related values
Server stateZustandTanStack QueryTanStack Query - Zustand for client-only state
DevToolsAlways onConditionalConditional - enabled: process.env.NODE_ENV === 'development'

Anti-Patterns (FORBIDDEN)

// ❌ 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

// ✅ 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
}
  • tanstack-query-advanced - Server state management (use with Zustand for client state)
  • form-state-patterns - Form state (React Hook Form vs Zustand for forms)
  • react-server-components-framework - RSC hydration considerations with Zustand

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

  • references/middleware-composition.md - Combining multiple middleware
  • scripts/store-template.ts - Production-ready store template
  • 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:

// 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:

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: references/middleware-composition.md (Middleware Execution Order, Why Order Matters)

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:

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

Correct:

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:

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

Correct:

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

Pitfall 3: Missing DevTools Type Import

Incorrect:

import { devtools } from 'zustand/middleware'; // TS errors — types not augmented

Correct:

import { devtools } from 'zustand/middleware';
import type {} from '@redux-devtools/extension'; // Required type augmentation

Pitfall 4: Missing Persist Version Migrations

Incorrect:

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

Correct:

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/middleware-composition.md (Common Pitfalls)

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:

// 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:

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/middleware-composition.md (TypeScript Typing for Middleware)


References (1)

Middleware Composition

Middleware Composition

Comprehensive guide to combining Zustand middleware in the correct order for production applications.

Middleware Execution Order

Middleware wraps from inside out. The innermost middleware executes first, outermost last.

┌─────────────────────────────────────────────────────────────┐
│ persist (outermost - serializes final state)                │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ devtools (records actions after transformation)       │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │ subscribeWithSelector (enables granular subs)   │  │  │
│  │  │  ┌───────────────────────────────────────────┐  │  │  │
│  │  │  │ immer (innermost - transforms mutations)  │  │  │  │
│  │  │  │                                           │  │  │  │
│  │  │  │   Your store logic lives here             │  │  │  │
│  │  │  │                                           │  │  │  │
│  │  │  └───────────────────────────────────────────┘  │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Why Order Matters

PositionMiddlewareReason
InnermostimmerTransforms draft mutations into immutable updates FIRST
MiddlesubscribeWithSelectorNeeds transformed (immutable) state to work correctly
MiddledevtoolsRecords actions AFTER immer transforms them
OutermostpersistSerializes the FINAL transformed state

Complete Production Setup

import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type {} from '@redux-devtools/extension'; // Required for devtools typing

interface AppState {
  // UI State
  sidebarOpen: boolean;
  theme: 'light' | 'dark' | 'system';

  // User preferences
  notifications: {
    email: boolean;
    push: boolean;
    sms: boolean;
  };

  // Actions
  toggleSidebar: () => void;
  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  updateNotification: (key: keyof AppState['notifications'], value: boolean) => void;
  reset: () => void;
}

const initialState = {
  sidebarOpen: true,
  theme: 'system' as const,
  notifications: {
    email: true,
    push: true,
    sms: false,
  },
};

export const useAppStore = create<AppState>()(
  persist(
    devtools(
      subscribeWithSelector(
        immer((set, get) => ({
          ...initialState,

          toggleSidebar: () =>
            set(
              (state) => { state.sidebarOpen = !state.sidebarOpen; },
              undefined,
              'ui/toggleSidebar' // Action name for devtools
            ),

          setTheme: (theme) =>
            set(
              (state) => { state.theme = theme; },
              undefined,
              'ui/setTheme'
            ),

          updateNotification: (key, value) =>
            set(
              (state) => { state.notifications[key] = value; },
              undefined,
              `notifications/update/${key}`
            ),

          reset: () =>
            set(
              () => initialState,
              true, // Replace entire state
              'app/reset'
            ),
        }))
      ),
      {
        name: 'AppStore',
        enabled: process.env.NODE_ENV === 'development',
        // Sanitize sensitive data from devtools
        serialize: {
          replacer: (key, value) => {
            if (key === 'password' || key === 'token') return '[REDACTED]';
            return value;
          },
        },
      }
    ),
    {
      name: 'app-storage',
      storage: createJSONStorage(() => localStorage),
      version: 2,

      // Only persist specific fields
      partialize: (state) => ({
        theme: state.theme,
        notifications: state.notifications,
        // Don't persist: sidebarOpen (session-only UI state)
      }),

      // Handle migrations between versions
      migrate: (persistedState: unknown, version: number) => {
        const state = persistedState as Partial<AppState>;

        if (version === 0) {
          // v0 → v1: Added notifications
          return {
            ...state,
            notifications: { email: true, push: true, sms: false },
          };
        }

        if (version === 1) {
          // v1 → v2: Changed theme from boolean to union
          return {
            ...state,
            theme: (state as any).darkMode ? 'dark' : 'light',
          };
        }

        return state as AppState;
      },

      // Called when hydration completes
      onRehydrateStorage: () => (state, error) => {
        if (error) {
          console.error('Failed to rehydrate store:', error);
        } else {
          console.log('Store rehydrated:', state?.theme);
        }
      },
    }
  )
);

subscribeWithSelector Usage

Enables subscribing to specific state slices outside React:

// Subscribe to theme changes only
const unsubscribe = useAppStore.subscribe(
  (state) => state.theme,
  (theme, prevTheme) => {
    console.log('Theme changed:', prevTheme, '→', theme);
    document.documentElement.dataset.theme = theme;
  },
  { fireImmediately: true }
);

// Subscribe with equality function
useAppStore.subscribe(
  (state) => state.notifications,
  (notifications) => {
    syncNotificationsToServer(notifications);
  },
  { equalityFn: shallow } // Only trigger if shallow-different
);

// Cleanup
unsubscribe();

Devtools Best Practices

Action Naming Convention

// ✅ GOOD: Namespace/action format
set(state => { ... }, undefined, 'cart/addItem');
set(state => { ... }, undefined, 'auth/login');
set(state => { ... }, undefined, 'ui/toggleSidebar');

// ❌ BAD: No action name (shows as "anonymous")
set(state => { ... });

Conditional DevTools

const useStore = create<State>()(
  devtools(
    (set) => ({ ... }),
    {
      name: 'MyStore',
      enabled: process.env.NODE_ENV === 'development',
      // Trace calls for debugging (performance cost!)
      trace: process.env.NODE_ENV === 'development',
      traceLimit: 25,
    }
  )
);

Persist Strategies

Session Storage (Tab-Scoped)

persist(
  (set) => ({ ... }),
  {
    name: 'session-store',
    storage: createJSONStorage(() => sessionStorage),
  }
)

IndexedDB (Large Data)

import { get, set, del } from 'idb-keyval';

const indexedDBStorage = {
  getItem: async (name: string) => {
    return (await get(name)) ?? null;
  },
  setItem: async (name: string, value: string) => {
    await set(name, value);
  },
  removeItem: async (name: string) => {
    await del(name);
  },
};

persist(
  (set) => ({ ... }),
  {
    name: 'large-store',
    storage: createJSONStorage(() => indexedDBStorage),
  }
)

Async Storage (React Native)

import AsyncStorage from '@react-native-async-storage/async-storage';

persist(
  (set) => ({ ... }),
  {
    name: 'mobile-store',
    storage: createJSONStorage(() => AsyncStorage),
  }
)

Middleware Without Full Stack

Immer Only (Simple Apps)

const useStore = create<State>()(
  immer((set) => ({
    items: [],
    addItem: (item) => set((state) => { state.items.push(item); }),
  }))
);

Persist Only (Simple Persistence)

const useStore = create<State>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    { name: 'theme-storage' }
  )
);

DevTools Only (Development)

const useStore = create<State>()(
  devtools(
    (set) => ({ ... }),
    { enabled: process.env.NODE_ENV === 'development' }
  )
);

TypeScript Typing for Middleware

When using multiple middleware, TypeScript needs explicit middleware type annotation:

import { create, StateCreator } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface BearState {
  bears: number;
  increase: () => void;
}

// Explicit middleware types for slices
type BearSlice = StateCreator<
  BearState,
  [['zustand/immer', never], ['zustand/devtools', never]],
  [],
  BearState
>;

const createBearSlice: BearSlice = (set) => ({
  bears: 0,
  increase: () => set((state) => { state.bears += 1; }),
});

const useStore = create<BearState>()(
  devtools(
    immer(createBearSlice),
    { name: 'BearStore' }
  )
);

Common Pitfalls

❌ Wrong Order

// WRONG: persist inside devtools
devtools(persist(immer(...))) // DevTools won't see persist actions

// CORRECT: persist outside devtools
persist(devtools(immer(...)))

❌ Duplicate Middleware

// WRONG: Double-wrapping
persist(persist(...)) // Causes hydration issues

❌ Missing Type Import

// WRONG: DevTools types missing
import { devtools } from 'zustand/middleware';

// CORRECT: Import type augmentation
import { devtools } from 'zustand/middleware';
import type {} from '@redux-devtools/extension';

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 from zustand/react/shallow (not deprecated zustand/shallow)

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

Examples (1)

Zustand Examples

Zustand Real-World Examples

Production-tested patterns for common use cases.

E-Commerce Cart Store

Complete shopping cart with persistence, optimistic updates, and computed totals.

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface CartItem {
  id: string;
  productId: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'id'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>()(
  persist(
    immer((set) => ({
      items: [],

      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.productId === item.productId);
          if (existing) {
            existing.quantity += item.quantity;
          } else {
            state.items.push({ ...item, id: crypto.randomUUID() });
          }
        }),

      removeItem: (id) =>
        set((state) => {
          state.items = state.items.filter((i) => i.id !== id);
        }),

      updateQuantity: (id, quantity) =>
        set((state) => {
          const item = state.items.find((i) => i.id === id);
          if (item) {
            item.quantity = Math.max(0, quantity);
            if (item.quantity === 0) {
              state.items = state.items.filter((i) => i.id !== id);
            }
          }
        }),

      clearCart: () => set({ items: [] }),
    })),
    {
      name: 'cart-storage',
      storage: createJSONStorage(() => localStorage),
    }
  )
);

// ✅ Computed selectors (not stored state)
export const useCartItemCount = () =>
  useCartStore((s) => s.items.reduce((sum, item) => sum + item.quantity, 0));

export const useCartSubtotal = () =>
  useCartStore((s) => s.items.reduce((sum, item) => sum + item.price * item.quantity, 0));

export const useCartTax = () => {
  const subtotal = useCartSubtotal();
  return subtotal * 0.1; // 10% tax
};

export const useCartTotal = () => {
  const subtotal = useCartSubtotal();
  const tax = useCartTax();
  return subtotal + tax;
};

// Usage in component
function CartSummary() {
  const itemCount = useCartItemCount();
  const subtotal = useCartSubtotal();
  const tax = useCartTax();
  const total = useCartTotal();

  return (
    <div>
      <p>{itemCount} items</p>
      <p>Subtotal: ${subtotal.toFixed(2)}</p>
      <p>Tax: ${tax.toFixed(2)}</p>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

Authentication Store with Token Refresh

Auth state with automatic token refresh and secure handling.

import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'user' | 'admin';
}

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;

  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  refreshAuth: () => Promise<void>;
  setUser: (user: User) => void;
}

export const useAuthStore = create<AuthState>()(
  subscribeWithSelector((set, get) => ({
    user: null,
    accessToken: null,
    refreshToken: null,
    isAuthenticated: false,
    isLoading: false,

    login: async (email, password) => {
      set({ isLoading: true });
      try {
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email, password }),
        });

        if (!response.ok) throw new Error('Login failed');

        const { user, accessToken, refreshToken } = await response.json();

        set({
          user,
          accessToken,
          refreshToken,
          isAuthenticated: true,
          isLoading: false,
        });
      } catch (error) {
        set({ isLoading: false });
        throw error;
      }
    },

    logout: () => {
      set({
        user: null,
        accessToken: null,
        refreshToken: null,
        isAuthenticated: false,
      });
    },

    refreshAuth: async () => {
      const { refreshToken } = get();
      if (!refreshToken) {
        get().logout();
        return;
      }

      try {
        const response = await fetch('/api/auth/refresh', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ refreshToken }),
        });

        if (!response.ok) {
          get().logout();
          return;
        }

        const { accessToken, refreshToken: newRefreshToken } = await response.json();

        set({
          accessToken,
          refreshToken: newRefreshToken,
        });
      } catch {
        get().logout();
      }
    },

    setUser: (user) => set({ user }),
  }))
);

// ✅ Auto-refresh token before expiry
if (typeof window !== 'undefined') {
  useAuthStore.subscribe(
    (state) => state.accessToken,
    (accessToken) => {
      if (accessToken) {
        // Decode JWT to get expiry (simplified)
        const payload = JSON.parse(atob(accessToken.split('.')[1]));
        const expiresAt = payload.exp * 1000;
        const refreshAt = expiresAt - 60000; // Refresh 1 min before expiry

        const timeout = setTimeout(() => {
          useAuthStore.getState().refreshAuth();
        }, refreshAt - Date.now());

        return () => clearTimeout(timeout);
      }
    }
  );
}

// ✅ Selectors
export const useUser = () => useAuthStore((s) => s.user);
export const useIsAuthenticated = () => useAuthStore((s) => s.isAuthenticated);
export const useIsAdmin = () => useAuthStore((s) => s.user?.role === 'admin');
export const useAccessToken = () => useAuthStore((s) => s.accessToken);

Theme Store with System Preference Sync

Theme management that syncs with system preferences.

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { subscribeWithSelector } from 'zustand/middleware';

type Theme = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';

interface ThemeState {
  theme: Theme;
  resolvedTheme: ResolvedTheme;
  setTheme: (theme: Theme) => void;
}

const getSystemTheme = (): ResolvedTheme =>
  typeof window !== 'undefined' &&
  window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';

const resolveTheme = (theme: Theme): ResolvedTheme =>
  theme === 'system' ? getSystemTheme() : theme;

export const useThemeStore = create<ThemeState>()(
  persist(
    subscribeWithSelector((set) => ({
      theme: 'system',
      resolvedTheme: getSystemTheme(),

      setTheme: (theme) =>
        set({
          theme,
          resolvedTheme: resolveTheme(theme),
        }),
    })),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ theme: state.theme }), // Only persist preference
      onRehydrateStorage: () => (state) => {
        // Resolve theme after hydration
        if (state) {
          state.resolvedTheme = resolveTheme(state.theme);
        }
      },
    }
  )
);

// ✅ Sync with system preference changes
if (typeof window !== 'undefined') {
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

  mediaQuery.addEventListener('change', () => {
    const { theme } = useThemeStore.getState();
    if (theme === 'system') {
      useThemeStore.setState({ resolvedTheme: getSystemTheme() });
    }
  });

  // Apply theme to document
  useThemeStore.subscribe(
    (state) => state.resolvedTheme,
    (resolvedTheme) => {
      document.documentElement.classList.remove('light', 'dark');
      document.documentElement.classList.add(resolvedTheme);
    },
    { fireImmediately: true }
  );
}

// ✅ Selectors
export const useTheme = () => useThemeStore((s) => s.theme);
export const useResolvedTheme = () => useThemeStore((s) => s.resolvedTheme);
export const useIsDarkMode = () => useThemeStore((s) => s.resolvedTheme === 'dark');

Multi-Step Form Wizard Store

Form wizard with step validation and draft persistence.

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface PersonalInfo {
  firstName: string;
  lastName: string;
  email: string;
}

interface AddressInfo {
  street: string;
  city: string;
  state: string;
  zip: string;
}

interface PaymentInfo {
  cardNumber: string;
  expiryDate: string;
  cvv: string;
}

interface WizardState {
  currentStep: number;
  personal: Partial<PersonalInfo>;
  address: Partial<AddressInfo>;
  payment: Partial<PaymentInfo>;
  completedSteps: Set<number>;

  setStep: (step: number) => void;
  nextStep: () => void;
  prevStep: () => void;
  updatePersonal: (data: Partial<PersonalInfo>) => void;
  updateAddress: (data: Partial<AddressInfo>) => void;
  updatePayment: (data: Partial<PaymentInfo>) => void;
  markStepComplete: (step: number) => void;
  reset: () => void;
}

const TOTAL_STEPS = 3;

const initialState = {
  currentStep: 0,
  personal: {},
  address: {},
  payment: {},
  completedSteps: new Set<number>(),
};

export const useWizardStore = create<WizardState>()(
  persist(
    immer((set) => ({
      ...initialState,

      setStep: (step) =>
        set((state) => {
          if (step >= 0 && step < TOTAL_STEPS) {
            state.currentStep = step;
          }
        }),

      nextStep: () =>
        set((state) => {
          if (state.currentStep < TOTAL_STEPS - 1) {
            state.currentStep += 1;
          }
        }),

      prevStep: () =>
        set((state) => {
          if (state.currentStep > 0) {
            state.currentStep -= 1;
          }
        }),

      updatePersonal: (data) =>
        set((state) => {
          Object.assign(state.personal, data);
        }),

      updateAddress: (data) =>
        set((state) => {
          Object.assign(state.address, data);
        }),

      updatePayment: (data) =>
        set((state) => {
          Object.assign(state.payment, data);
        }),

      markStepComplete: (step) =>
        set((state) => {
          state.completedSteps.add(step);
        }),

      reset: () => set(initialState),
    })),
    {
      name: 'wizard-draft',
      storage: createJSONStorage(() => sessionStorage), // Tab-scoped
      partialize: (state) => ({
        currentStep: state.currentStep,
        personal: state.personal,
        address: state.address,
        // Don't persist payment info for security
      }),
    }
  )
);

// ✅ Selectors
export const useCurrentStep = () => useWizardStore((s) => s.currentStep);
export const useIsFirstStep = () => useWizardStore((s) => s.currentStep === 0);
export const useIsLastStep = () => useWizardStore((s) => s.currentStep === TOTAL_STEPS - 1);
export const useWizardProgress = () => useWizardStore((s) => ((s.currentStep + 1) / TOTAL_STEPS) * 100);

Notification Toast Store

Global notification system with auto-dismiss.

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

type NotificationType = 'info' | 'success' | 'warning' | 'error';

interface Notification {
  id: string;
  type: NotificationType;
  title: string;
  message?: string;
  duration?: number;
  dismissible?: boolean;
}

interface NotificationState {
  notifications: Notification[];
  add: (notification: Omit<Notification, 'id'>) => string;
  remove: (id: string) => void;
  clear: () => void;
}

const DEFAULT_DURATION = 5000;

export const useNotificationStore = create<NotificationState>()(
  immer((set, get) => ({
    notifications: [],

    add: (notification) => {
      const id = crypto.randomUUID();
      const duration = notification.duration ?? DEFAULT_DURATION;

      set((state) => {
        state.notifications.push({
          ...notification,
          id,
          dismissible: notification.dismissible ?? true,
        });
      });

      // Auto-dismiss after duration
      if (duration > 0) {
        setTimeout(() => {
          get().remove(id);
        }, duration);
      }

      return id;
    },

    remove: (id) =>
      set((state) => {
        state.notifications = state.notifications.filter((n) => n.id !== id);
      }),

    clear: () => set({ notifications: [] }),
  }))
);

// ✅ Convenience functions
export const toast = {
  info: (title: string, message?: string) =>
    useNotificationStore.getState().add({ type: 'info', title, message }),

  success: (title: string, message?: string) =>
    useNotificationStore.getState().add({ type: 'success', title, message }),

  warning: (title: string, message?: string) =>
    useNotificationStore.getState().add({ type: 'warning', title, message }),

  error: (title: string, message?: string) =>
    useNotificationStore.getState().add({ type: 'error', title, message, duration: 0 }),
};

// ✅ Selectors
export const useNotifications = () => useNotificationStore((s) => s.notifications);
export const useHasNotifications = () => useNotificationStore((s) => s.notifications.length > 0);
Edit on GitHub

Last updated on

On this page

Zustand PatternsOverviewCore Patterns1. Basic Store with TypeScript2. Slices Pattern (Modular Stores)3. Immer Middleware (Immutable Updates)4. Persist Middleware5. Selectors (Prevent Re-renders)6. Async Actions7. DevTools IntegrationQuick ReferenceKey DecisionsAnti-Patterns (FORBIDDEN)Integration with React QueryRelated SkillsCapability Detailsstore-creationslices-patternmiddleware-stackselector-optimizationpersistence-migrationReferencesRules (3)Nest Zustand middleware in the correct order to prevent devtools and persist failures — CRITICALZustand: Middleware OrderAvoid Zustand middleware pitfalls that cause silent reactivity breaks and hydration failures — HIGHZustand: Middleware PitfallsPitfall 1: Mutating State Without ImmerPitfall 2: Duplicate Middleware / Wrong NestingPitfall 3: Missing DevTools Type ImportPitfall 4: Missing Persist Version MigrationsUse the Zustand slice pattern to keep stores maintainable and avoid merge conflicts — HIGHZustand: Slice PatternReferences (1)Middleware CompositionMiddleware CompositionMiddleware Execution OrderWhy Order MattersComplete Production SetupsubscribeWithSelector UsageDevtools Best PracticesAction Naming ConventionConditional DevToolsPersist StrategiesSession Storage (Tab-Scoped)IndexedDB (Large Data)Async Storage (React Native)Middleware Without Full StackImmer Only (Simple Apps)Persist Only (Simple Persistence)DevTools Only (Development)TypeScript Typing for MiddlewareCommon Pitfalls❌ Wrong Order❌ Duplicate Middleware❌ Missing Type ImportChecklists (1)Zustand ChecklistZustand Implementation ChecklistStore SetupTypeScript ConfigurationStore StructureMiddleware StackSelectorsBasic SelectorsMulti-Value SelectorsComputed ValuesAction SelectorsPersistenceConfigurationMigrationsStorage SelectionDevToolsConfigurationAction NamingPerformanceRe-render PreventionBundle SizeTestingTest SetupTest CoverageIntegrationReact Query SeparationSSR/RSC ConsiderationsCode OrganizationFile StructureNaming ConventionsSecurityDocumentationExamples (1)Zustand ExamplesZustand Real-World ExamplesE-Commerce Cart StoreAuthentication Store with Token RefreshTheme Store with System Preference SyncMulti-Step Form Wizard StoreNotification Toast Store