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
- Subscribing to the entire store - Causes re-renders on every state change
- Storing derived data - Compute it in selectors or components instead
- One mega-store - Split into domain-specific stores
- Storing server state - Use SWR or TanStack Query for server data; Zustand is for client state
- Not using useShallow for object selectors - Object selectors create new references every render
- 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:
- Architecture rules - Store structure, naming, and organization conventions
- Performance patterns - Selector optimization to prevent unnecessary re-renders
- Middleware recipes - Persist, devtools, immer, and how to combine them
- 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.mdGotchas
- 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. Useset(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
onRehydrateStoragecallback 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
| Approach | When to Use |
|---|---|
| Jotai | Atomic state model, bottom-up approach |
| Valtio | Proxy-based, mutable API |
| Redux Toolkit | Large teams, complex middleware needs, time-travel debugging |
| React Context | Simple 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
useShallowwhen 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
zustandimport - 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?
persistsaves store state to localStorage (or other storage)partializelets 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
HydrationGuardcomponent that waits foruseEffectbefore rendering - Alternatively, use the
onRehydrateStoragecallback 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
StateCreatortype - 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
setStateto 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