zustand
Zustand Patterns
Basic Store
import { create } from "zustand";
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Selectors
Always select only the state you need — this prevents re-renders when unrelated state changes:
// Select individual values
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
// Select multiple values with useShallow
import { useShallow } from "zustand/shallow";
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment })),
);
Never destructure the entire store without a selector:
// Bad — re-renders on every state change
const { count, increment } = useCounterStore();
// Good — re-renders only when count changes
const count = useCounterStore((state) => state.count);
Async Actions
interface UserStore {
user: User | null;
isLoading: boolean;
error: string | null;
fetchUser: (id: string) => Promise<void>;
}
const useUserStore = create<UserStore>((set) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (id) => {
set({ isLoading: true, error: null });
try {
const user = await api.users.getById(id);
set({ user, isLoading: false });
} catch (error) {
set({ error: "Failed to fetch user", isLoading: false });
}
},
}));
For server data, prefer TanStack Query over Zustand — Zustand is for client-only state.
Middleware
Immer
Write mutable-looking updates safely:
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({ id: crypto.randomUUID(), text, completed: false });
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}),
})),
);
Persist
Sync state to storage:
import { persist } from "zustand/middleware";
const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: "light",
language: "en",
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: "settings",
partialize: (state) => ({
theme: state.theme,
language: state.language,
}),
},
),
);
nameis the storage key.partializecontrols which state is persisted — exclude functions and transient state.- Default storage is
localStorage. Usestorage: createJSONStorage(() => sessionStorage)for session storage.
Devtools
import { devtools } from "zustand/middleware";
const useStore = create<Store>()(
devtools(
(set) => ({
// ...
}),
{ name: "MyStore" },
),
);
Combining Middleware
Stack middleware from inside out — immer → persist → devtools:
const useStore = create<Store>()(
devtools(
persist(
immer((set) => ({
// store definition
})),
{ name: "store-key" },
),
{ name: "StoreName" },
),
);
Slice Pattern
Split large stores into logical slices:
interface AuthSlice {
user: User | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
interface UISlice {
sidebarOpen: boolean;
toggleSidebar: () => void;
}
const createAuthSlice: StateCreator<AuthSlice & UISlice, [], [], AuthSlice> = (set) => ({
user: null,
login: async (credentials) => {
const user = await api.auth.login(credentials);
set({ user });
},
logout: () => set({ user: null }),
});
const createUISlice: StateCreator<AuthSlice & UISlice, [], [], UISlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
});
const useAppStore = create<AuthSlice & UISlice>()((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}));
Computed / Derived State
Derive values in selectors, not in the store:
// In the component or a custom hook
const completedCount = useTodoStore((state) => state.todos.filter((t) => t.completed).length);
// For expensive computations, memoize
const stats = useTodoStore(
useShallow((state) => ({
total: state.todos.length,
completed: state.todos.filter((t) => t.completed).length,
})),
);
Accessing State Outside React
// Get current state
const count = useCounterStore.getState().count;
// Subscribe to changes
const unsubscribe = useCounterStore.subscribe((state) => console.log("Count:", state.count));
// Set state from outside React
useCounterStore.getState().increment();
Store Organization
src/
├── stores/
│ ├── auth-store.ts
│ ├── settings-store.ts
│ └── ui-store.ts
- One store per domain concern.
- Keep stores small and focused — don't create a single global "app store".
- Name stores with the
use*Storeconvention.
When to Use Zustand vs. Alternatives
| Use Case | Solution |
|---|---|
| Client UI state (theme, sidebar, modals) | Zustand |
| Server data (API responses, caching) | TanStack Query |
| Form state | React Hook Form |
| URL state (filters, pagination) | URL search params |
| Component-local state | useState / useReducer |
| Global shared state (auth, preferences) | Zustand |
More from grahamcrackers/skills
bulletproof-react-patterns
Bulletproof React architecture patterns for scalable, maintainable applications. Covers feature-based project structure, component patterns, state management boundaries, API layer design, error handling, security, and testing strategies. Use when structuring a React project, designing application architecture, organizing features, or when the user asks about React project structure or scalable patterns.
45react-aria-components
React Aria Components patterns for building accessible, unstyled UI with composition-based architecture. Covers component structure, styling with Tailwind and CSS, render props, collections, forms, selections, overlays, and drag-and-drop. Use when building accessible components, using react-aria-components, creating design systems, or when the user asks about React Aria, accessible UI primitives, or headless component libraries.
17clean-code-principles
Clean code principles for readable, maintainable TypeScript and React codebases. Covers naming, functions, abstraction, composition, error handling, comments, and code smells. Use when writing new code, refactoring, reviewing code quality, or when the user asks about clean code, readability, or maintainability.
10typescript-best-practices
Core TypeScript conventions for type safety, inference, and clean code. Use when writing TypeScript, reviewing TypeScript code, creating interfaces/types, or when the user asks about TypeScript patterns, conventions, or best practices.
9tanstack-query
TanStack Query v5 patterns for server state management, caching, mutations, optimistic updates, and query organization. Use when working with TanStack Query, React Query, server state, data fetching hooks, or when the user asks about caching strategies, query invalidation, or mutation patterns.
8react-best-practices
Modern React 19 patterns for components, hooks, state management, performance, and project structure. Use when writing React components, reviewing React code, designing component APIs, or when the user asks about React conventions, architecture, or best practices.
7