//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
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 \}),
\}));// 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
\}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),
\}));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
\}
)
);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" \}
)
);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 \});
\}),
\}))
);const useStore = create<StoreState>()(
devtools(
persist(
immer((set) => (\{
// store definition
\})),
\{ name: "my-store" \}
),
\{ name: "MyStore" \}
)
);
// Order: immer (innermost) -> persist -> devtools (outermost)// 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
\}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 \});
\}
\},
\}));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);
\});
## 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);
Skill-guided response: Diagnose the selector pattern - likely subscribing to the full store or using an object selector without useShallow.
This skill provides:
mkdir -p .claude/skills/zustand-state-management
# Paste the Recipe content into .claude/skills/zustand-state-management/SKILL.mdset({ count: 1 }) merges with existing state, it does not replace it. Use set(state => state, true) for a full replacement.onRehydrateStorage callback or a hydration guard.
| 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 |
// Bad: re-renders on ANY state change
const store = useCounterStore();
// Good: subscribes only to count
const count = useCounterStore((state) => state.count);import { useShallow } from "zustand/react/shallow";
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
);useShallow when your selector returns an object (multiple values)from "zustand/react/shallow"zustand importimport { 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 })),
}));create<State>()const useStore = create<StoreState>()(
devtools(
persist(
immer((set) => ({ /* ... */ })),
{ name: "my-store" }
),
{ name: "MyStore" }
)
);persist saves store state to localStorage (or other storage)partialize lets you select which fields to persist (exclude actions and derived data)createJSONStorage(() => localStorage) for the storage adapterset({ count: 1 }) merges shallowly with existing state (does not replace the entire store)set(newState, true) with the replace flagHydrationGuard component that waits for useEffect before renderingonRehydrateStorage callback from persist middlewareStateCreator typecreate() call// 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);
});setState to reset between testsReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥