| name | zustand-patterns |
| description | Zustand store management with slices, middleware, selectors, and TypeScript patterns |
Zustand Patterns — Global State Management
Overview
Zustand patterns for client state:
- Curried syntax with TypeScript
- Store slices organization
- Middleware (devtools, persist, immer)
- Granular selectors
- Feature-scoped vs global stores
- When to use Zustand vs TanStack Query vs Context
Hard Rules
These rules are NON-NEGOTIABLE. Violating any of them is a bug.
- ALWAYS use curried syntax in TypeScript:
create<State>()((...) => ({...}))
- NEVER destructure entire store — ALWAYS use granular selectors
- ALWAYS use
useShallow when selecting multiple values
- ALWAYS define state + actions in one interface
- NEVER mutate state directly unless using immer middleware
- ALWAYS use
devtools middleware in development
- ALWAYS use
partialize with persist — NEVER persist entire store
- NEVER store server/API data in Zustand — use TanStack Query for server state
- ALWAYS reset stores between tests via
useStore.setState(useStore.getInitialState())
- NEVER use Zustand for form state — use React Hook Form
State Management Decision Matrix
| Concern | Tool | Why |
|---|
| Server/API data | TanStack Query | Caching, deduplication, background refresh |
| Global client state | Zustand | Lightweight, no boilerplate, fast |
| Local component state | useState / useReducer | Simplest option, no external deps |
| Rarely-changing tree-wide values | React Context | Theme, locale, auth user |
| Form state | React Hook Form | Validation, field management, performance |
| URL-driven state | Router search params | Shareable, bookmarkable, SSR-friendly |
Rule: If the data comes from an API, it goes in TanStack Query. If it's client-only UI state, use Zustand. If it's scoped to one component, use useState.
Store Setup — Curried Syntax
Why Curried Syntax?
TypeScript cannot infer generics on create<State>() without currying. The extra () enables full type inference.
const useStore = create<State>((set) => ({...}));
const useStore = create<State>()((...) => ({...}));
Basic Store
import { create } from 'zustand';
interface UIState {
sidebarOpen: boolean;
theme: 'light' | 'dark' | 'system';
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setTheme: (theme: UIState['theme']) => void;
}
export const useUIStore = create<UIState>()((set) => ({
sidebarOpen: true,
theme: 'system',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setTheme: (theme) => set({ theme }),
}));
Granular Selectors
Single Value Selector
function Sidebar(): React.ReactElement {
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
return sidebarOpen ? <nav>Sidebar content</nav> : null;
}
Multiple Values with useShallow
import { useShallow } from 'zustand/react/shallow';
function Header(): React.ReactElement {
const { sidebarOpen, theme, toggleSidebar } = useUIStore(
useShallow((state) => ({
sidebarOpen: state.sidebarOpen,
theme: state.theme,
toggleSidebar: state.toggleSidebar,
}))
);
return (
<header>
<button onClick={toggleSidebar}>
{sidebarOpen ? 'Close' : 'Open'}
</button>
<span>Theme: {theme}</span>
</header>
);
}
function Header(): React.ReactElement {
const { sidebarOpen, theme, toggleSidebar } = useUIStore();
}
Reusable Selector Hooks
import { useUIStore } from './ui-store';
export function useSidebarOpen(): boolean {
return useUIStore((state) => state.sidebarOpen);
}
export function useTheme(): UIState['theme'] {
return useUIStore((state) => state.theme);
}
function Sidebar(): React.ReactElement {
const sidebarOpen = useSidebarOpen();
}
Slices Pattern
For stores with multiple domains, split into slices that combine into one store.
Auth Slice
import type { StateCreator } from 'zustand';
interface User {
id: string;
email: string;
name: string;
}
export interface AuthSlice {
user: User | null;
isAuthenticated: boolean;
setUser: (user: User) => void;
logout: () => void;
}
export const createAuthSlice: StateCreator<
AuthSlice & UISlice,
[],
[],
AuthSlice
> = (set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
});
UI Slice
import type { StateCreator } from 'zustand';
export interface UISlice {
sidebarOpen: boolean;
theme: 'light' | 'dark' | 'system';
toggleSidebar: () => void;
setTheme: (theme: UISlice['theme']) => void;
}
export const createUISlice: StateCreator<
AuthSlice & UISlice,
[],
[],
UISlice
> = (set) => ({
sidebarOpen: true,
theme: 'system',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
});
Combining Slices
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { AuthSlice } from './slices/auth-slice';
import { createAuthSlice } from './slices/auth-slice';
import type { UISlice } from './slices/ui-slice';
import { createUISlice } from './slices/ui-slice';
type AppStore = AuthSlice & UISlice;
export const useAppStore = create<AppStore>()(
devtools(
persist(
immer((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
})),
{
name: 'app-store',
partialize: (state) => ({
theme: state.theme,
sidebarOpen: state.sidebarOpen,
}),
}
),
{ name: 'AppStore' }
)
);
Middleware
Middleware Ordering
The correct nesting order (outermost to innermost):
devtools( persist( immer( storeCreator ) ) )
Why this order:
devtools wraps everything — sees final state changes
persist persists after immer processes
immer enables direct mutations in the innermost layer
devtools — Development Debugging
import { devtools } from 'zustand/middleware';
const useStore = create<State>()(
devtools(
(set) => ({
count: 0,
increment: () => set(
(state) => ({ count: state.count + 1 }),
undefined,
'increment'
),
}),
{ name: 'CounterStore' }
)
);
persist — LocalStorage
import { persist } from 'zustand/middleware';
const useStore = create<State>()(
persist(
(set) => ({
theme: 'system',
sidebarOpen: true,
user: null,
setTheme: (theme) => set({ theme }),
}),
{
name: 'ui-preferences',
partialize: (state) => ({
theme: state.theme,
sidebarOpen: state.sidebarOpen,
}),
}
)
);
immer — Direct Mutations
import { immer } from 'zustand/middleware/immer';
interface TodoState {
todos: Array<{ id: string; text: string; done: boolean }>;
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => 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;
}
}),
removeTodo: (id) =>
set((state) => {
const index = state.todos.findIndex((t) => t.id === id);
if (index !== -1) {
state.todos.splice(index, 1);
}
}),
}))
);
Feature-Scoped Stores
For non-global state that belongs to a specific feature:
import { create } from 'zustand';
interface KanbanState {
draggedCardId: string | null;
activeColumn: string | null;
setDraggedCard: (id: string | null) => void;
setActiveColumn: (column: string | null) => void;
}
export const useKanbanStore = create<KanbanState>()((set) => ({
draggedCardId: null,
activeColumn: null,
setDraggedCard: (id) => set({ draggedCardId: id }),
setActiveColumn: (column) => set({ activeColumn: column }),
}));
Rule: Feature-scoped stores live in src/features/<name>/stores/. They are NOT imported by other features — only by the feature's own components and hooks.
Testing Stores
Reset Between Tests
import { beforeEach } from 'vitest';
import { useAppStore } from '@/stores/app-store';
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState());
});
Testing Store Logic (Without Rendering)
import { describe, it, expect, beforeEach } from 'vitest';
import { useAppStore } from '@/stores/app-store';
describe('AppStore - Auth Slice', () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState());
});
it('sets user on login', () => {
const user = { id: '1', email: 'test@example.com', name: 'Test' };
useAppStore.getState().setUser(user);
expect(useAppStore.getState().user).toEqual(user);
expect(useAppStore.getState().isAuthenticated).toBe(true);
});
it('clears user on logout', () => {
useAppStore.getState().setUser({ id: '1', email: 'test@example.com', name: 'Test' });
useAppStore.getState().logout();
expect(useAppStore.getState().user).toBeNull();
expect(useAppStore.getState().isAuthenticated).toBe(false);
});
});
Testing Components That Use Stores
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { useUIStore } from '@/stores/ui-store';
import { Sidebar } from './Sidebar';
describe('Sidebar', () => {
beforeEach(() => {
useUIStore.setState(useUIStore.getInitialState());
});
it('shows sidebar when open', () => {
useUIStore.setState({ sidebarOpen: true });
render(<Sidebar />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('hides sidebar when closed', () => {
useUIStore.setState({ sidebarOpen: false });
render(<Sidebar />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('toggles sidebar on button click', async () => {
const user = userEvent.setup();
render(<Sidebar />);
await user.click(screen.getByRole('button', { name: /toggle/i }));
expect(useUIStore.getState().sidebarOpen).toBe(false);
});
});
Common Mistakes
❌ Server State in Zustand
const useStore = create<State>()((set) => ({
users: [],
fetchUsers: async () => {
const users = await api.getUsers();
set({ users });
},
}));
const usersQuery = queryOptions({
queryKey: ['users'],
queryFn: fetchUsers,
});
❌ Destructuring Entire Store
const { theme } = useStore();
const theme = useStore((state) => state.theme);
❌ Persisting Sensitive Data
persist(store, {
name: 'app',
});
persist(store, {
name: 'app',
partialize: (state) => ({
theme: state.theme,
sidebarOpen: state.sidebarOpen,
}),
});
Summary
- ✅ Curried syntax for TypeScript inference
- ✅ Granular selectors,
useShallow for multiple values
- ✅ Slices pattern for organized domain logic
- ✅ Middleware ordering:
devtools(persist(immer(...)))
- ✅
partialize on persist — never persist entire store
- ✅ Server state in TanStack Query, client state in Zustand
- ✅ Reset stores in tests via
getInitialState()