Zustand State Management
This skill covers designing and using Zustand stores in React and TypeScript applications, including state ownership decisions, store slicing, selector performance, middleware, SSR, and testing.
State Ownership
- Keep ephemeral UI state in the nearest component with
useState or useReducer — don't reach for Zustand by default.
- Use URL state for shareable filters, pagination, tabs, and search params.
- Use Zustand only for client state that is genuinely shared across unrelated components (auth session view state, command palette, cart draft, editor state, etc.).
- Use TanStack Query, SWR, RTK Query, or the project's existing data layer for server state — don't duplicate fetched server data into a Zustand store unless there's a documented offline or draft-editing requirement.
- Implement functional, declarative patterns; avoid classes. Use descriptive variable names with auxiliary verbs like
isLoading, hasError.
Store Design
- Model each store as state plus named actions; avoid exposing anonymous setters for component code to misuse.
- Keep stores small and domain-focused rather than one global store for the entire application.
- Split large stores into typed slices, then apply middleware only at the composed store boundary.
- Keep derived values as selectors or small pure helpers unless they must be cached in state.
- Store serializable data by default; keep DOM nodes, promises, sockets, and timers outside store state.
import { create } from 'zustand'
interface SidebarState {
isOpen: boolean
activePanelId: string | null
}
interface SidebarActions {
openPanel: (panelId: string) => void
close: () => void
toggle: () => void
}
type SidebarStore = SidebarState & SidebarActions
export const useSidebarStore = create<SidebarStore>()((set) => ({
isOpen: false,
activePanelId: null,
openPanel: (panelId) => set({ isOpen: true, activePanelId: panelId }),
close: () => set({ isOpen: false, activePanelId: null }),
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
}))
Component Usage & Performance
- Subscribe to the smallest possible slice:
useStore((state) => state.value). Avoid selecting the entire store.
- Do not call a store hook without a selector in components unless the component truly needs every field.
- Use
useShallow (from zustand/react/shallow) when a selector returns an object or tuple of multiple values, to avoid re-renders from a new object reference each call.
- Keep selectors pure and cheap; move expensive derivations into memoized helpers.
import { useShallow } from 'zustand/react/shallow'
import { useSidebarStore } from '@/stores/sidebar-store'
export function SidebarToggle() {
const { isOpen, toggle } = useSidebarStore(
useShallow((state) => ({
isOpen: state.isOpen,
toggle: state.toggle,
})),
)
return (
<button type="button" aria-expanded={isOpen}
Toggle sidebar
</button>
)
}
TypeScript
- Define explicit state and action interfaces for shared stores; combine them (
type Store = State & Actions) rather than one flat interface for non-trivial stores.
- Avoid
any; use unknown plus narrowing for external data.
- Type action payloads and return values, including async actions.
- Prefer discriminated unions for complex local status instead of several loosely related booleans.
- Export store state types when tests, utilities, or vanilla store factories need them.
Middleware
Updates
- Use functional
set((state) => nextState) when the next value depends on current state.
- Treat nested state immutably; install and use the
immer middleware only when it materially simplifies nested updates — don't mutate nested objects directly without it.
Persistence
import { persist } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({
// state and actions
}),
{ name: 'store-key' }
)
)
- Use
persist only for state that must survive reloads.
- Use
partialize, version, and migrate when persisting anything beyond trivial preferences.
- Never persist secrets, access tokens, refresh tokens, raw PII, or other long-lived authorization state to browser storage.
DevTools
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools((set) => ({
// state and actions
}))
)
- Use
devtools in development for complex flows and give important actions clear names so they're identifiable in the trace.
Non-React Subscriptions
- Use
subscribeWithSelector for non-React subscriptions that need fine-grained updates outside of component rendering.
Async Actions & Error Handling
- Async store actions should coordinate client-only workflows, optimistic drafts, or local device APIs — keep HTTP fetching in the project's server-state layer unless the state is explicitly client-owned.
- Represent async client workflows with explicit statuses such as
idle, pending, success, and error rather than ad hoc booleans.
- Handle errors at function start using early returns and guard clauses; use try-catch in async actions and provide meaningful error messages.
- Reset error state deliberately when retrying or closing a workflow.
SSR and React Server Components
- Do not read or mutate browser-only stores from React Server Components.
- In SSR frameworks (Next.js App Router, etc.), create per-request vanilla stores when state must be initialized on the server, rather than a shared module-level store.
- Guard persisted stores against hydration mismatches before rendering storage-backed values.
- Keep store modules free of direct
window, document, and storage access outside middleware configuration.
Testing
- Test stores independently of components — test store actions directly without rendering React when possible.
- Reset stores between tests with their initial state.
- Assert selectors and actions separately from component behavior; mock Zustand stores in component tests when isolating UI logic.
- Mock server-state libraries instead of routing fetched data through Zustand for tests.
- Test middleware behavior (persistence, devtools) separately from core store logic.
Anti-Patterns
- Do not create one global store for the entire application.
- Do not put form input state in Zustand unless multiple distant components edit the same draft.
- Do not mutate nested objects directly without Immer middleware.
- Do not use Zustand as an event bus; prefer explicit callbacks, services, or a scoped store.
- Do not introduce Redux-style reducers, action constants, or dispatch wrappers unless the project already uses that pattern.
1---2name: zustand-state-management3description: Best practices for Zustand state management in React and TypeScript applications, covering store design, selectors, middleware, SSR, and testing. Use when creating or refactoring Zustand stores, deciding whether state belongs in Zustand vs component state vs a server-state library, optimizing selector re-renders, adding persist/devtools/immer middleware, or handling Zustand with SSR/React Server Components.4---5
6# Zustand State Management
7
8This skill covers designing and using Zustand stores in React and TypeScript applications, including state ownership decisions, store slicing, selector performance, middleware, SSR, and testing.
9
10## State Ownership
11
12- Keep ephemeral UI state in the nearest component with `useState` or `useReducer` — don't reach for Zustand by default.
13- Use URL state for shareable filters, pagination, tabs, and search params.
14- Use Zustand only for client state that is genuinely shared across unrelated components (auth session view state, command palette, cart draft, editor state, etc.).
15- Use TanStack Query, SWR, RTK Query, or the project's existing data layer for server state — don't duplicate fetched server data into a Zustand store unless there's a documented offline or draft-editing requirement.
16- Implement functional, declarative patterns; avoid classes. Use descriptive variable names with auxiliary verbs like `isLoading`, `hasError`.
17
18## Store Design
19
20- Model each store as state plus named actions; avoid exposing anonymous setters for component code to misuse.
21- Keep stores small and domain-focused rather than one global store for the entire application.
22- Split large stores into typed slices, then apply middleware only at the composed store boundary.
23- Keep derived values as selectors or small pure helpers unless they must be cached in state.
24- Store serializable data by default; keep DOM nodes, promises, sockets, and timers outside store state.
25
26```typescript
27import { create } from 'zustand'
28
29interface SidebarState {
30 isOpen: boolean
31 activePanelId: string | null
32}
33
34interface SidebarActions {
35 openPanel: (panelId: string) => void
36 close: () => void
37 toggle: () => void
38}
39
40type SidebarStore = SidebarState & SidebarActions
41
42export const useSidebarStore = create<SidebarStore>()((set) => ({
43 isOpen: false,
44 activePanelId: null,
45 openPanel: (panelId) => set({ isOpen: true, activePanelId: panelId }),
46 close: () => set({ isOpen: false, activePanelId: null }),
47 toggle: () => set((state) => ({ isOpen: !state.isOpen })),
48}))
49```
50
51## Component Usage & Performance
52
53- Subscribe to the smallest possible slice: `useStore((state) => state.value)`. Avoid selecting the entire store.
54- Do not call a store hook without a selector in components unless the component truly needs every field.
55- Use `useShallow` (from `zustand/react/shallow`) when a selector returns an object or tuple of multiple values, to avoid re-renders from a new object reference each call.
56- Keep selectors pure and cheap; move expensive derivations into memoized helpers.
57
58```tsx
59import { useShallow } from 'zustand/react/shallow'
60import { useSidebarStore } from '@/stores/sidebar-store'
61
62export function SidebarToggle() {
63 const { isOpen, toggle } = useSidebarStore(
64 useShallow((state) => ({
65 isOpen: state.isOpen,
66 toggle: state.toggle,
67 })),
68 )
69
70 return (
71 <button type="button" aria-expanded={isOpen} onClick={toggle}>
72 Toggle sidebar
73 </button>
74 )
75}
76```
77
78## TypeScript
79
80- Define explicit state and action interfaces for shared stores; combine them (`type Store = State & Actions`) rather than one flat interface for non-trivial stores.
81- Avoid `any`; use `unknown` plus narrowing for external data.
82- Type action payloads and return values, including async actions.
83- Prefer discriminated unions for complex local status instead of several loosely related booleans.
84- Export store state types when tests, utilities, or vanilla store factories need them.
85
86## Middleware
87
88### Updates
89- Use functional `set((state) => nextState)` when the next value depends on current state.
90- Treat nested state immutably; install and use the `immer` middleware only when it materially simplifies nested updates — don't mutate nested objects directly without it.
91
92### Persistence
93```typescript
94import { persist } from 'zustand/middleware'
95
96const useStore = create(
97 persist(
98 (set) => ({
99 // state and actions
100 }),
101 { name: 'store-key' }
102 )
103)
104```
105- Use `persist` only for state that must survive reloads.
106- Use `partialize`, `version`, and `migrate` when persisting anything beyond trivial preferences.
107- Never persist secrets, access tokens, refresh tokens, raw PII, or other long-lived authorization state to browser storage.
108
109### DevTools
110```typescript
111import { devtools } from 'zustand/middleware'
112
113const useStore = create(
114 devtools((set) => ({
115 // state and actions
116 }))
117)
118```
119- Use `devtools` in development for complex flows and give important actions clear names so they're identifiable in the trace.
120
121### Non-React Subscriptions
122- Use `subscribeWithSelector` for non-React subscriptions that need fine-grained updates outside of component rendering.
123
124## Async Actions & Error Handling
125
126- Async store actions should coordinate client-only workflows, optimistic drafts, or local device APIs — keep HTTP fetching in the project's server-state layer unless the state is explicitly client-owned.
127- Represent async client workflows with explicit statuses such as `idle`, `pending`, `success`, and `error` rather than ad hoc booleans.
128- Handle errors at function start using early returns and guard clauses; use try-catch in async actions and provide meaningful error messages.
129- Reset error state deliberately when retrying or closing a workflow.
130
131## SSR and React Server Components
132
133- Do not read or mutate browser-only stores from React Server Components.
134- In SSR frameworks (Next.js App Router, etc.), create per-request vanilla stores when state must be initialized on the server, rather than a shared module-level store.
135- Guard persisted stores against hydration mismatches before rendering storage-backed values.
136- Keep store modules free of direct `window`, `document`, and storage access outside middleware configuration.
137
138## Testing
139
140- Test stores independently of components — test store actions directly without rendering React when possible.
141- Reset stores between tests with their initial state.
142- Assert selectors and actions separately from component behavior; mock Zustand stores in component tests when isolating UI logic.
143- Mock server-state libraries instead of routing fetched data through Zustand for tests.
144- Test middleware behavior (persistence, devtools) separately from core store logic.
145
146## Anti-Patterns
147
148- Do not create one global store for the entire application.
149- Do not put form input state in Zustand unless multiple distant components edit the same draft.
150- Do not mutate nested objects directly without Immer middleware.
151- Do not use Zustand as an event bus; prefer explicit callbacks, services, or a scoped store.
152- Do not introduce Redux-style reducers, action constants, or dispatch wrappers unless the project already uses that pattern.