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.
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
| 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 (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/shallowIntegration 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
}Related Skills
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 middlewarescripts/store-template.ts- Production-ready store templatechecklists/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(...))notimmer(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-renderCorrect:
set((state) => ({ items: [...state.items, item] })); // Immutable update
immer((set) => ({ addItem: (item) => set((s) => { s.items.push(item); }) })) // With immerPitfall 2: Duplicate Middleware / Wrong Nesting
Incorrect:
persist(persist((set) => ({ /* ... */ }), { name: 'a' }), { name: 'b' }) // Double-wrapCorrect:
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 augmentedCorrect:
import { devtools } from 'zustand/middleware';
import type {} from '@redux-devtools/extension'; // Required type augmentationPitfall 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 droppedCorrect:
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) inset()withoutimmermiddleware - Never double-wrap the same middleware -- each should appear exactly once
- Always
import type \{\} from '@redux-devtools/extension'when usingdevtools - Always provide a
migratefunction when bumping persistversion
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<CombinedStore, MiddlewareMutators, [], SliceInterface>for full store type inference - Combine with spread:
create<Store>()((...a) => (\{ ...createSliceA(...a), ...createSliceB(...a) \}))--...aforwardsset,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
StateCreatorgeneric 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
| Position | Middleware | Reason |
|---|---|---|
| Innermost | immer | Transforms draft mutations into immutable updates FIRST |
| Middle | subscribeWithSelector | Needs transformed (immutable) state to work correctly |
| Middle | devtools | Records actions AFTER immer transforms them |
| Outermost | persist | Serializes 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<State>()()double-call pattern used for type inference - Action return types are
void(mutations viaset()) -
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
-
useShallowused for selecting multiple related values - Import from
zustand/react/shallow(not deprecatedzustand/shallow)
Computed Values
- Derived state computed in selectors, not stored
- Expensive computations memoized with
useMemoif needed
Action Selectors
- Action selectors exported for stable references
- Actions grouped by domain:
useAuthActions(),useCartActions()
Persistence
Configuration
-
partializeused to persist only necessary fields - Ephemeral state excluded (loading, errors, UI toggles)
- Storage key is unique and descriptive
Migrations
-
versionfield set (start at 1) -
migratefunction handles all version transitions - Migrations are tested
-
onRehydrateStoragehandles 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/actionordomain/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
-
useStoreonly 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 typesNaming 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);Web Research Workflow
Unified decision tree for web research and competitive monitoring. Auto-selects WebFetch, Tavily, or agent-browser based on target site characteristics and available API keys. Includes competitor page tracking, snapshot diffing, and change alerting. Use when researching web content, scraping, extracting raw markdown, capturing documentation, or monitoring competitor changes.
Agents Reference
Complete reference for all 38 OrchestKit agents.
Last updated on