React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

skillszustandstate-managementtypescriptselectorsmiddleware

Zustand State Management Skill - A Claude Code skill recipe for building scalable global state with Zustand

These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.

Recipe

The complete SKILL.md content you can copy into .claude/skills/zustand-state-management/SKILL.md:

---
name: zustand-state-management
description: "Building scalable, performant global state with Zustand and TypeScript. Use when asked to: zustand help, global state, store pattern, state management, zustand selectors, zustand middleware, zustand persist, zustand SSR."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
 
# Zustand State Management
 
You are a Zustand expert. Help developers build scalable, performant, and well-typed state management.
 
## Store Architecture Rules
 
1. **One store per domain** - auth store, cart store, ui store. Never one giant store.
2. **Flat state** - Avoid deeply nested objects. Normalize data like a database.
3. **Colocate actions with state** - Keep actions in the same store as the state they modify.
4. **Use selectors** - Never subscribe to the entire store. Always select the minimum data needed.
5. **Derive, do not store** - Computed values should be derived in selectors, not stored.
 
## Core Patterns
 
### Basic Store with TypeScript
```tsx
import \{ create \} from "zustand";
 
interface CounterState \{
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
\}
 
const useCounterStore = create<CounterState>((set) => (\{
  count: 0,
  increment: () => set((state) => (\{ count: state.count + 1 \})),
  decrement: () => set((state) => (\{ count: state.count - 1 \})),
  reset: () => set(\{ count: 0 \}),
\}));

Selector Patterns (Prevent Re-renders)

// BAD - subscribes to entire store, re-renders on ANY change
function Component() \{
  const store = useCounterStore();
  return <span>\{store.count\}</span>;
\}
 
// GOOD - subscribes only to count
function Component() \{
  const count = useCounterStore((state) => state.count);
  return <span>\{count\}</span>;
\}
 
// GOOD - multiple values with shallow comparison
import \{ useShallow \} from "zustand/react/shallow";
 
function Component() \{
  const \{ count, increment \} = useCounterStore(
    useShallow((state) => (\{ count: state.count, increment: state.increment \}))
  );
  return <button onClick=\{increment\}>\{count\}</button>;
\}
 
// GOOD - stable action reference (actions never change)
function Component() \{
  const increment = useCounterStore((state) => state.increment);
  // increment reference is stable, no re-renders from other state changes
\}

Slices Pattern (Modular Stores)

interface AuthSlice \{
  user: User | null;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
\}
 
interface CartSlice \{
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
\}
 
const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = (
  set
) => (\{
  user: null,
  login: async (credentials) => \{
    const user = await api.login(credentials);
    set(\{ user \});
  \},
  logout: () => set(\{ user: null \}),
\});
 
const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = (
  set,
  get
) => (\{
  items: [],
  addItem: (item) => set((state) => (\{ items: [...state.items, item] \})),
  removeItem: (id) =>
    set((state) => (\{ items: state.items.filter((i) => i.id !== id) \})),
  total: () => get().items.reduce((sum, item) => sum + item.price, 0),
\});
 
const useStore = create<AuthSlice & CartSlice>()((...args) => (\{
  ...createAuthSlice(...args),
  ...createCartSlice(...args),
\}));

Middleware

Persist

import \{ persist, createJSONStorage \} from "zustand/middleware";
 
const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => (\{
      theme: "light" as const,
      language: "en",
      setTheme: (theme: "light" | "dark") => set(\{ theme \}),
      setLanguage: (language: string) => set(\{ language \}),
    \}),
    \{
      name: "settings-storage",
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => (\{
        theme: state.theme,
        language: state.language,
      \}), // Only persist these fields, not actions
    \}
  )
);

Devtools

import \{ devtools \} from "zustand/middleware";
 
const useStore = create<StoreState>()(
  devtools(
    (set) => (\{
      count: 0,
      increment: () =>
        set(
          (state) => (\{ count: state.count + 1 \}),
          false,
          "increment" // action name in devtools
        ),
    \}),
    \{ name: "MyStore" \}
  )
);

Immer (for complex updates)

import \{ immer \} from "zustand/middleware/immer";
 
const useTodoStore = create<TodoState>()(
  immer((set) => (\{
    todos: [],
    toggleTodo: (id: string) =>
      set((state) => \{
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.completed = !todo.completed; // direct mutation is safe with immer
      \}),
    addTodo: (text: string) =>
      set((state) => \{
        state.todos.push(\{ id: crypto.randomUUID(), text, completed: false \});
      \}),
  \}))
);

Combining Middleware

const useStore = create<StoreState>()(
  devtools(
    persist(
      immer((set) => (\{
        // store definition
      \})),
      \{ name: "my-store" \}
    ),
    \{ name: "MyStore" \}
  )
);
// Order: immer (innermost) -> persist -> devtools (outermost)

SSR Hydration (Next.js)

// stores/counter-store.ts
import \{ create \} from "zustand";
 
interface CounterState \{
  count: number;
  increment: () => void;
\}
 
export const useCounterStore = create<CounterState>((set) => (\{
  count: 0,
  increment: () => set((state) => (\{ count: state.count + 1 \})),
\}));
 
// To prevent hydration mismatch with persist middleware:
// components/HydrationGuard.tsx
"use client";
 
import \{ useEffect, useState \} from "react";
 
export function HydrationGuard(\{ children \}: \{ children: React.ReactNode \}) \{
  const [hydrated, setHydrated] = useState(false);
  useEffect(() => setHydrated(true), []);
  return hydrated ? <>\{children\}</> : null; // or a skeleton
\}

Async Actions

interface ProductState \{
  products: Product[];
  loading: boolean;
  error: string | null;
  fetchProducts: () => Promise<void>;
\}
 
const useProductStore = create<ProductState>((set) => (\{
  products: [],
  loading: false,
  error: null,
  fetchProducts: async () => \{
    set(\{ loading: true, error: null \});
    try \{
      const products = await api.getProducts();
      set(\{ products, loading: false \});
    \} catch (error) \{
      set(\{ error: (error as Error).message, loading: false \});
    \}
  \},
\}));

Testing

import \{ renderHook, act \} from "@testing-library/react";
import \{ useCounterStore \} from "./counter-store";
 
// Reset store between tests
beforeEach(() => \{
  useCounterStore.setState(\{ count: 0 \});
\});
 
test("increment increases count", () => \{
  const \{ result \} = renderHook(() =>
    useCounterStore((state) => (\{
      count: state.count,
      increment: state.increment,
    \}))
  );
 
  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
\});
 
// Test store directly without React
test("store works without components", () => \{
  const \{ getState \} = useCounterStore;
  expect(getState().count).toBe(0);
  getState().increment();
  expect(getState().count).toBe(1);
\});

Anti-Patterns

  1. Subscribing to the entire store - Causes re-renders on every state change
  2. Storing derived data - Compute it in selectors or components instead
  3. One mega-store - Split into domain-specific stores
  4. Storing server state - Use SWR or TanStack Query for server data; Zustand is for client state
  5. Not using useShallow for object selectors - Object selectors create new references every render
  6. Putting non-state in the store - React refs, DOM elements, and subscriptions do not belong in Zustand

## Working Example

### Example 1: User asks "Set up a Zustand store for my shopping cart"

**User prompt:** "I need a cart store with add, remove, update quantity, and total calculation."

**Skill-guided response would produce:**

```tsx
// stores/cart-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

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

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, "quantity">) => 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.id === item.id);
          if (existing) {
            existing.quantity += 1;
          } else {
            state.items.push({ ...item, quantity: 1 });
          }
        }),
      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);
        }),
      clearCart: () => set({ items: [] }),
    })),
    {
      name: "cart-storage",
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ items: state.items }),
    }
  )
);

// Derived selectors (defined outside the store)
export const selectCartTotal = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

export const selectCartCount = (state: CartState) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0);

Example 2: User asks "Why does my component re-render when unrelated state changes?"

Skill-guided response: Diagnose the selector pattern - likely subscribing to the full store or using an object selector without useShallow.

Deep Dive

How the Skill Works

This skill provides:

  1. Architecture rules - Store structure, naming, and organization conventions
  2. Performance patterns - Selector optimization to prevent unnecessary re-renders
  3. Middleware recipes - Persist, devtools, immer, and how to combine them
  4. Anti-pattern detection - Common Zustand mistakes and how to fix them

Customization

  • Add your project-specific store conventions
  • Include custom middleware (e.g., logging, analytics tracking)
  • Define naming conventions for stores and selectors
  • Add SSR-specific patterns for your framework

How to Install

mkdir -p .claude/skills/zustand-state-management
# Paste the Recipe content into .claude/skills/zustand-state-management/SKILL.md

Gotchas

  • Actions are stable references - You do not need to wrap them in useCallback or include them in dependency arrays.
  • set() merges shallowly - set({ count: 1 }) merges with existing state, it does not replace it. Use set(state => state, true) for a full replacement.
  • persist hydration is async - The persisted state loads asynchronously, which can cause a flash of default values. Use onRehydrateStorage callback or a hydration guard.
  • useShallow is from zustand/react/shallow - Not from the main zustand import. This is a common import mistake.
  • Middleware order matters - Immer must be innermost, devtools outermost.

Alternatives

ApproachWhen to Use
JotaiAtomic state model, bottom-up approach
ValtioProxy-based, mutable API
Redux ToolkitLarge teams, complex middleware needs, time-travel debugging
React ContextSimple state shared between a few components
Signals (Preact)Fine-grained reactivity without selectors

FAQs

What are the five store architecture rules for Zustand?
  • One store per domain (auth, cart, UI -- never one giant store)
  • Flat state (avoid deeply nested objects, normalize data)
  • Colocate actions with state in the same store
  • Use selectors (never subscribe to the entire store)
  • Derive computed values in selectors, do not store them
Why should you never subscribe to the entire Zustand store?
// Bad: re-renders on ANY state change
const store = useCounterStore();
 
// Good: subscribes only to count
const count = useCounterStore((state) => state.count);
  • Subscribing to the full store causes the component to re-render on every state change
  • Always select the minimum data the component needs
When and how do you use useShallow with Zustand?
import { useShallow } from "zustand/react/shallow";
 
const { count, increment } = useCounterStore(
  useShallow((state) => ({ count: state.count, increment: state.increment }))
);
  • Use useShallow when your selector returns an object (multiple values)
  • Without it, the object reference changes every render, causing unnecessary re-renders
Gotcha: What is the correct import path for useShallow?
  • The correct import is from "zustand/react/shallow"
  • It is NOT from the main zustand import
  • This is a common mistake that causes import errors
How do you type a Zustand store with TypeScript?
import { create } from "zustand";
 
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}
 
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
  • Define an interface for the full state shape including actions
  • Pass the interface as a generic to create<State>()
What is the correct middleware ordering when combining immer, persist, and devtools?
const useStore = create<StoreState>()(
  devtools(
    persist(
      immer((set) => ({ /* ... */ })),
      { name: "my-store" }
    ),
    { name: "MyStore" }
  )
);
  • Immer must be innermost, devtools must be outermost
  • Order: immer -> persist -> devtools
How does the persist middleware work and what is partialize for?
  • persist saves store state to localStorage (or other storage)
  • partialize lets you select which fields to persist (exclude actions and derived data)
  • Use createJSONStorage(() => localStorage) for the storage adapter
Gotcha: How does set() merge state -- shallow or deep?
  • set({ count: 1 }) merges shallowly with existing state (does not replace the entire store)
  • For a full state replacement, use set(newState, true) with the replace flag
  • Nested objects are NOT deeply merged -- you must spread nested updates manually
How do you handle SSR hydration mismatches with persisted Zustand stores?
  • Persisted state loads asynchronously, which can cause a flash of default values
  • Use a HydrationGuard component that waits for useEffect before rendering
  • Alternatively, use the onRehydrateStorage callback from persist middleware
What is the slices pattern and when should you use it?
  • The slices pattern lets you compose a store from multiple independent slice creators
  • Each slice defines its own state and actions with a StateCreator type
  • Merge slices in the final create() call
  • Use when a single domain store grows too large or when you want modular organization
How do you test Zustand stores?
// Reset store between tests
beforeEach(() => {
  useCounterStore.setState({ count: 0 });
});
 
// Test without React
test("increment works", () => {
  const { getState } = useCounterStore;
  getState().increment();
  expect(getState().count).toBe(1);
});
  • Use setState to reset between tests
  • You can test stores directly without rendering React components
Why should you use SWR or TanStack Query instead of Zustand for server data?
  • Zustand is designed for client state (UI state, form state, user preferences)
  • Server data needs deduplication, background refetching, cache invalidation, and stale-while-revalidate
  • SWR and TanStack Query provide these features out of the box
  • Storing server data in Zustand leads to manual cache management and stale data bugs