
Zustand
- 337 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
zustand is a developer skill that assists with Zustand state-management tasks for developers who need to implement and refactor React client state stores.
About
zustand is a development skill focused on assisting Zustand-related tasks in React applications. zustand is intended to help developers implement or refactor client state stores, connect selectors to components, and reason about state updates during UI development. zustand is typically used while building features that require shared client state across multiple components, such as filters, carts, editors, or dashboard settings. Developers reach for zustand when they want a lightweight store pattern and need help generating store structure, actions, and usage examples that match their existing codebase conventions and TypeScript types.
- zustand
Zustand by the numbers
- 337 all-time installs (skills.sh)
- +14 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,237 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill zustandAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 337 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I structure a Zustand store?
Use zustand for development tasks
Who is it for?
zustand is best for developers working in React codebases who use Zustand and need help shaping stores, actions, and component wiring.
Skip if: zustand is not for developers who are not using Zustand or who need server-side state management instead of client stores.
When should I use this skill?
Invoke when a developer mentions Zustand, store slices, selectors, or refactoring React client state to Zustand.
What you get
Updated Zustand store definitions, selectors/actions guidance, and integration snippets for React components.
- state store changes
Files
Community Zustand Best Practices
Comprehensive performance and architecture guide for Zustand state management in React applications. Contains 43 rules across 8 categories, prioritized by impact from critical (store architecture, selector optimization) to incremental (advanced patterns).
When to Apply
Reference these guidelines when:
- Creating new Zustand stores
- Optimizing re-render performance with selectors
- Implementing persistence or middleware
- Integrating Zustand with SSR/Next.js
- Reviewing code for state management patterns
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Store Architecture | CRITICAL | store- |
| 2 | Selector Optimization | CRITICAL | select- |
| 3 | Re-render Prevention | HIGH | render- |
| 4 | State Updates | MEDIUM-HIGH | update- |
| 5 | Middleware Configuration | MEDIUM | mw- |
| 6 | SSR and Hydration | MEDIUM | ssr- |
| 7 | TypeScript Patterns | LOW-MEDIUM | ts- |
| 8 | Advanced Patterns | LOW | adv- |
Quick Reference
1. Store Architecture (CRITICAL)
- `store-multiple-stores` - Use multiple small stores instead of one monolithic store
- `store-separate-actions` - Separate actions from state in dedicated namespace
- `store-event-naming` - Name actions as events not setters
- `store-colocate-logic` - Colocate actions with the state they modify
- `store-avoid-derived-state` - Derive computed values instead of storing them
- `store-domain-boundaries` - Organize stores by feature domain
2. Selector Optimization (CRITICAL)
- `select-always-use` - Always use selectors never subscribe to entire store
- `select-atomic-picks` - Use atomic selectors for single values
- `select-stable-returns` - Ensure selectors return stable references
- `select-custom-hooks` - Export custom hooks not raw store
- `select-auto-generate` - Use auto-generated selectors for large stores
- `select-memoize-computed` - Memoize expensive computed selectors
- `select-avoid-inline` - Define selectors outside components
3. Re-render Prevention (HIGH)
- `render-use-shallow` - Use useShallow for multi-property selections
- `render-equality-fn` - Provide custom equality functions when needed
- `render-memo-children` - Memo children affected by parent store updates
- `render-subscribe-external` - Use subscribe for non-React consumers
- `render-avoid-object-returns` - Avoid returning new objects from selectors
- `render-split-components` - Split components to minimize subscription scope
4. State Updates (MEDIUM-HIGH)
- `update-functional-set` - Use functional form when updating based on previous state
- `update-immutable` - Never mutate state directly
- `update-shallow-merge` - Understand set() shallow merge behavior
- `update-async-actions` - Handle async actions with loading and error states
- `update-batch-updates` - Batch related updates in single set call
5. Middleware Configuration (MEDIUM)
- `mw-devtools-actions` - Name actions for DevTools debugging
- `mw-persist-partialize` - Use partialize for selective persistence
- `mw-persist-migration` - Version and migrate persisted state
- `mw-immer-nested` - Use immer for deeply nested state updates
- `mw-combine-order` - Apply middlewares in correct order
- `mw-slice-middleware` - Apply middleware at combined store level
6. SSR and Hydration (MEDIUM)
- `ssr-skip-hydration` - Use skipHydration in SSR contexts
- `ssr-manual-rehydrate` - Manually rehydrate on client mount
- `ssr-hydration-hook` - Use custom hook to prevent hydration mismatch
- `ssr-check-window` - Guard browser APIs with typeof window check
7. TypeScript Patterns (LOW-MEDIUM)
- `ts-state-creator` - Use StateCreator for slice typing
- `ts-middleware-inference` - Preserve type inference with middleware
- `ts-separate-types` - Separate state and actions interfaces
- `ts-generic-selectors` - Type selectors for reusability
- `ts-bound-store` - Type combined stores correctly
8. Advanced Patterns (LOW)
- `adv-context-stores` - Combine Zustand with React Context for dependency injection
- `adv-transient-updates` - Use subscribe for transient updates
- `adv-computed-getters` - Implement computed state with getters
- `adv-third-party-integration` - Integrate with React Query and SWR
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on performance or correctness implications.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{// Comments explaining the cost or problem}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{// Comments explaining the benefit}{Optional sections as needed:}
Alternative ({context}):
{Alternative approach when applicable}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"name": "zustand",
"version": "1.0.6",
"organization": "Community",
"technology": "Zustand",
"date": "January 2026",
"abstract": "Comprehensive performance and architecture guide for Zustand state management in React applications, designed for AI agents and LLMs. Contains 43 rules across 8 categories, prioritized by impact from critical (store architecture, selector optimization) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://zustand.docs.pmnd.rs/",
"https://github.com/pmndrs/zustand",
"https://tkdodo.eu/blog/working-with-zustand",
"https://tkdodo.eu/blog/zustand-and-react-context",
"https://zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow",
"https://zustand.docs.pmnd.rs/middlewares/persist"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Store Architecture (store)
Impact: CRITICAL Description: Foundational store design determines all downstream performance. Multiple small stores beat monolithic stores.
2. Selector Optimization (select)
Impact: CRITICAL Description: Selectors are the #1 cause of unnecessary re-renders. Atomic selectors with stable returns are essential.
3. Re-render Prevention (render)
Impact: HIGH Description: Zustand uses strict equality by default. Object/array selectors need useShallow or memoization.
4. State Updates (update)
Impact: MEDIUM-HIGH Description: Immutable updates, functional setState, and action patterns affect predictability and debugging.
5. Middleware Configuration (mw)
Impact: MEDIUM Description: Devtools, persist, and immer middleware setup for developer experience and persistence.
6. SSR and Hydration (ssr)
Impact: MEDIUM Description: Next.js and SSR contexts require skipHydration and manual rehydration to avoid mismatches.
7. TypeScript Patterns (ts)
Impact: LOW-MEDIUM Description: Type inference, StateCreator patterns, and proper slice typing for type-safe stores.
8. Advanced Patterns (adv)
Impact: LOW Description: Context integration, external subscriptions, and computed state for specialized use cases.
Implement Computed State with Getters
For computed values that need to be accessed within actions, implement getter functions using get(). This keeps derived logic in the store while avoiding redundant state storage.
Incorrect (stores computed values):
const useCartStore = create<CartState>((set) => ({
items: [],
subtotal: 0, // Stored, must be kept in sync
tax: 0, // Stored, must be kept in sync
total: 0, // Stored, must be kept in sync
addItem: (item) => set((s) => {
const newItems = [...s.items, item]
const subtotal = newItems.reduce((sum, i) => sum + i.price, 0)
const tax = subtotal * 0.1
// Must update all computed values manually
return {
items: newItems,
subtotal,
tax,
total: subtotal + tax,
}
}),
}))Correct (computed getters):
const useCartStore = create<CartState>((set, get) => ({
items: [],
taxRate: 0.1,
// Getters compute on demand
getSubtotal: () => {
return get().items.reduce((sum, item) => sum + item.price, 0)
},
getTax: () => {
return get().getSubtotal() * get().taxRate
},
getTotal: () => {
return get().getSubtotal() + get().getTax()
},
// Actions can use getters
addItem: (item) => set((s) => ({
items: [...s.items, item],
})),
canCheckout: () => {
return get().items.length > 0 && get().getTotal() > 0
},
}))
// Usage in components via selectors
function CartTotal() {
const getTotal = useCartStore((s) => s.getTotal)
return <span>Total: ${getTotal()}</span>
}Note: Getters accessed via selectors don't trigger re-renders automatically. If you need reactive updates, select the underlying state instead:
// This won't re-render when items change
const total = useCartStore((s) => s.getTotal())
// This will re-render when items change
const items = useCartStore((s) => s.items)
const total = useMemo(
() => items.reduce((sum, i) => sum + i.price, 0),
[items]
)Reference: Zustand Documentation
Combine Zustand with React Context for Dependency Injection
Use React Context to provide store instances instead of global singletons. This enables per-component-tree state, easier testing, and SSR isolation.
Incorrect (global singleton, hard to test):
// Global store - all tests share state
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
// Tests leak state between each other
test('counter starts at 0', () => {
// Previous test's state might still be here
expect(useCounterStore.getState().count).toBe(0)
})Correct (context-provided store):
import { createContext, useContext, useRef } from 'react'
import { createStore, useStore } from 'zustand'
// Store factory function
const createCounterStore = (initialCount = 0) =>
createStore<CounterState>((set) => ({
count: initialCount,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
type CounterStore = ReturnType<typeof createCounterStore>
// Context for store instance
const CounterContext = createContext<CounterStore | null>(null)
// Provider creates isolated store instance
function CounterProvider({
children,
initialCount = 0,
}: {
children: React.ReactNode
initialCount?: number
}) {
const storeRef = useRef<CounterStore>()
if (!storeRef.current) {
storeRef.current = createCounterStore(initialCount)
}
return (
<CounterContext.Provider value={storeRef.current}>
{children}
</CounterContext.Provider>
)
}
// Hook to access store from context
function useCounterStore<T>(selector: (state: CounterState) => T): T {
const store = useContext(CounterContext)
if (!store) throw new Error('Missing CounterProvider')
return useStore(store, selector)
}
// Usage - each Provider has isolated state
function App() {
return (
<>
<CounterProvider initialCount={0}>
<Counter /> {/* This counter is independent */}
</CounterProvider>
<CounterProvider initialCount={100}>
<Counter /> {/* This counter starts at 100 */}
</CounterProvider>
</>
)
}When NOT to use this pattern:
- Simple apps where global singleton behavior is desired
- When you don't need multiple independent store instances
- When Context provider overhead is not justified for your use case
Benefits:
- Easy testing with fresh store per test
- Multiple independent store instances in one app
- SSR: each request gets isolated state
- Dependency injection for store configuration
Reference: TkDodo - Zustand and React Context
Integrate with React Query and SWR
Use Zustand for client state (UI, preferences, forms) and data-fetching libraries (React Query, SWR) for server state. Combine them via custom hooks when needed.
Incorrect (mixing server and client state in Zustand):
// Anti-pattern: Zustand managing server data
const useUserStore = create<UserState>((set) => ({
users: [],
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true })
try {
const users = await api.getUsers()
set({ users, isLoading: false })
} catch (error) {
set({ error, isLoading: false })
}
},
// Manual cache invalidation, no background refresh
// No deduplication, no retry logic
}))Correct (Zustand for client state, React Query for server state):
// Zustand: client/UI state only
const useUIStore = create<UIState>((set) => ({
selectedUserId: null,
filterText: '',
sortOrder: 'asc',
setSelectedUser: (id) => set({ selectedUserId: id }),
setFilter: (text) => set({ filterText: text }),
setSortOrder: (order) => set({ sortOrder: order }),
}))
// React Query: server state
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: api.getUsers,
staleTime: 5 * 60 * 1000,
})
}
// Custom hook combining both
function useFilteredUsers() {
const { data: users = [], isLoading } = useUsers()
const filterText = useUIStore((s) => s.filterText)
const sortOrder = useUIStore((s) => s.sortOrder)
const filteredUsers = useMemo(() => {
let result = users.filter((u) =>
u.name.toLowerCase().includes(filterText.toLowerCase())
)
return sortOrder === 'asc'
? result.sort((a, b) => a.name.localeCompare(b.name))
: result.sort((a, b) => b.name.localeCompare(a.name))
}, [users, filterText, sortOrder])
return { users: filteredUsers, isLoading }
}
// Component uses the combined hook
function UserList() {
const { users, isLoading } = useFilteredUsers()
const setSelectedUser = useUIStore((s) => s.setSelectedUser)
if (isLoading) return <Spinner />
return (
<ul>
{users.map((user) => (
<li key={user.id} onClick={() => setSelectedUser(user.id)}>
{user.name}
</li>
))}
</ul>
)
}Benefits:
- React Query handles caching, deduplication, background refresh
- Zustand handles UI state that doesn't belong in URL or server
- Clear separation of concerns
- Each tool does what it's best at
Reference: Working with Zustand - TkDodo
Use subscribe for Transient Updates
For high-frequency updates or non-React integrations (animations, WebGL, analytics), use subscribe to react to state changes without triggering React re-renders.
Incorrect (React re-renders for every update):
function CursorTracker() {
const position = useCursorStore((s) => s.position)
// Re-renders 60+ times per second during mouse movement
useEffect(() => {
// Expensive analytics on every re-render
analytics.track('cursor_position', position)
}, [position])
return <Cursor x={position.x} y={position.y} />
}Correct (transient subscription for analytics):
function CursorTracker() {
const cursorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Subscribe outside React render cycle
const unsubscribe = useCursorStore.subscribe(
(state, prevState) => {
// Update DOM directly (no re-render)
if (cursorRef.current) {
cursorRef.current.style.transform =
`translate(${state.position.x}px, ${state.position.y}px)`
}
// Throttled analytics (doesn't cause re-render)
if (Math.abs(state.position.x - prevState.position.x) > 50) {
analytics.track('cursor_moved', state.position)
}
}
)
return unsubscribe
}, [])
return <div ref={cursorRef} className="cursor" />
}Alternative (selective subscription with subscribeWithSelector):
import { subscribeWithSelector } from 'zustand/middleware'
const useGameStore = create<GameState>()(
subscribeWithSelector((set) => ({
score: 0,
health: 100,
position: { x: 0, y: 0 },
// ...
}))
)
// Subscribe only to score changes
useGameStore.subscribe(
(state) => state.score,
(score, prevScore) => {
if (score > prevScore) {
playSound('point')
showFloatingText(`+${score - prevScore}`)
}
}
)
// Subscribe to health with custom equality
useGameStore.subscribe(
(state) => state.health,
(health) => {
if (health <= 0) {
triggerGameOver()
}
},
{ equalityFn: (a, b) => Math.floor(a / 10) === Math.floor(b / 10) }
)Use cases:
- Canvas/WebGL rendering
- Audio engine updates
- Analytics/logging
- Third-party library sync
Reference: Zustand - subscribeWithSelector
Apply Middlewares in Correct Order
Middleware order matters. Each middleware wraps the next, so the outermost middleware processes state changes first. Follow this order: devtools (outer) → persist → immer (inner).
Incorrect (wrong order breaks devtools):
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
// Wrong: persist outside devtools won't show persisted state in DevTools
persist(
devtools(
immer((set) => ({
count: 0,
increment: () => set((s) => { s.count++ }),
}))
),
{ name: 'store' }
)
)Correct (proper middleware order):
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(
// Correct order: devtools → persist → immer
devtools(
persist(
immer((set) => ({
count: 0,
increment: () => set((s) => { s.count++ }),
})),
{ name: 'store' }
),
{ name: 'store', enabled: process.env.NODE_ENV === 'development' }
)
)Recommended middleware order:
1. devtools (outermost) - sees all state changes for debugging
2. subscribeWithSelector - adds selective subscriptions
3. persist - saves/restores state
4. immer (innermost) - transforms mutations to immutable updatesWhy this order:
devtoolsshould see final state changes, so it wraps everythingpersistshould save the immer-processed state, not raw mutationsimmeris innermost because it transforms howsetworks
Reference: Zustand Documentation - Middlewares
Name Actions for DevTools Debugging
When using the devtools middleware, provide action names as the third argument to set. This enables meaningful action labels in Redux DevTools for easier debugging.
Incorrect (unnamed actions):
import { devtools } from 'zustand/middleware'
const useBearStore = create<BearState>()(
devtools((set) => ({
bears: 0,
honey: 100,
// Actions appear as "anonymous" in DevTools
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
eatHoney: () => set((s) => ({ honey: s.honey - 10 })),
}))
)
// DevTools shows: "anonymous", "anonymous" - hard to traceCorrect (named actions):
import { devtools } from 'zustand/middleware'
const useBearStore = create<BearState>()(
devtools((set) => ({
bears: 0,
honey: 100,
increasePopulation: () =>
set(
(s) => ({ bears: s.bears + 1 }),
undefined, // replace flag (false = merge)
'bears/increasePopulation' // action name
),
eatHoney: () =>
set(
(s) => ({ honey: s.honey - 10 }),
undefined,
'bears/eatHoney'
),
}))
)
// DevTools shows: "bears/increasePopulation", "bears/eatHoney"Alternative (slices with namespaced actions):
const createBearSlice: StateCreator<
BearStore,
[['zustand/devtools', never]],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () =>
set(
(s) => ({ bears: s.bears + 1 }),
undefined,
'bear/addBear'
),
})
const createFishSlice: StateCreator<
BearStore,
[['zustand/devtools', never]],
[],
FishSlice
> = (set) => ({
fish: 0,
addFish: () =>
set(
(s) => ({ fish: s.fish + 1 }),
undefined,
'fish/addFish'
),
})Benefits:
- Clear action history in DevTools
- Time-travel debugging becomes useful
- Easier to trace state changes in complex apps
Reference: Zustand - Devtools Middleware
Use Immer for Deeply Nested State Updates
For stores with deeply nested objects, immer middleware lets you write mutations that are automatically converted to immutable updates. This eliminates spread operator chains.
Incorrect (manual spreading for nested updates):
const useFormStore = create<FormState>((set) => ({
form: {
sections: {
personal: {
fields: {
firstName: { value: '', error: null, touched: false },
lastName: { value: '', error: null, touched: false },
},
},
address: {
fields: {
street: { value: '', error: null, touched: false },
city: { value: '', error: null, touched: false },
},
},
},
},
setFieldValue: (section, field, value) => set((state) => ({
form: {
...state.form,
sections: {
...state.form.sections,
[section]: {
...state.form.sections[section],
fields: {
...state.form.sections[section].fields,
[field]: {
...state.form.sections[section].fields[field],
value,
},
},
},
},
},
})),
}))Correct (immer middleware):
import { immer } from 'zustand/middleware/immer'
const useFormStore = create<FormState>()(
immer((set) => ({
form: {
sections: {
personal: {
fields: {
firstName: { value: '', error: null, touched: false },
lastName: { value: '', error: null, touched: false },
},
},
address: {
fields: {
street: { value: '', error: null, touched: false },
city: { value: '', error: null, touched: false },
},
},
},
},
// Direct mutation syntax, immer handles immutability
setFieldValue: (section, field, value) => set((state) => {
state.form.sections[section].fields[field].value = value
}),
setFieldError: (section, field, error) => set((state) => {
state.form.sections[section].fields[field].error = error
}),
touchField: (section, field) => set((state) => {
state.form.sections[section].fields[field].touched = true
}),
}))
)When to use immer:
- Deeply nested state (3+ levels)
- Array operations (push, splice, filter in-place)
- Complex conditional updates
When NOT to use:
- Simple flat state
- Performance-critical hot paths (immer has overhead)
Reference: Zustand - Immer Middleware
Version and Migrate Persisted State
When your state shape changes, users with old persisted data will have issues. Use version and migrate to transform old state to the new shape.
Incorrect (no versioning, breaks on schema change):
// Version 1: Original schema
const useUserStore = create<UserState>()(
persist(
(set) => ({
username: '', // Renamed to 'name' in v2
email: '',
}),
{ name: 'user-storage' }
)
)
// Later, you rename username to name:
// Old persisted data: { username: 'john' }
// New schema expects: { name: 'john' }
// Result: name is undefined, username ignoredCorrect (versioned with migration):
import { persist } from 'zustand/middleware'
interface UserStateV2 {
name: string // Renamed from username
email: string
preferences: { // New in v2
theme: string
}
}
const useUserStore = create<UserStateV2>()(
persist(
(set) => ({
name: '',
email: '',
preferences: { theme: 'light' },
}),
{
name: 'user-storage',
version: 2,
migrate: (persisted: unknown, version: number) => {
const state = persisted as Record<string, unknown>
if (version === 0 || version === 1) {
// Migrate from v1 to v2
return {
name: state.username || state.name || '',
email: state.email || '',
preferences: state.preferences || { theme: 'light' },
}
}
return state as UserStateV2
},
}
)
)Alternative (multi-step migrations):
const migrations: Record<number, (state: unknown) => unknown> = {
1: (state: any) => ({
...state,
name: state.username,
username: undefined,
}),
2: (state: any) => ({
...state,
preferences: state.preferences || { theme: 'light' },
}),
}
const useUserStore = create<UserState>()(
persist(
(set) => ({ /* ... */ }),
{
name: 'user-storage',
version: 2,
migrate: (persisted, version) => {
let state = persisted
for (let v = version; v < 2; v++) {
state = migrations[v + 1]?.(state) || state
}
return state as UserState
},
}
)
)Reference: Zustand - Persist Middleware
Use partialize for Selective Persistence
Don't persist the entire store. Use partialize to select only the data that should survive page reloads. Exclude sensitive data, derived state, and temporary UI state.
Incorrect (persists everything):
import { persist } from 'zustand/middleware'
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null, // Sensitive, shouldn't persist long-term
refreshToken: null, // Sensitive
isLoading: false, // Transient UI state
error: null, // Transient
loginAttempts: 0, // Temporary
login: (credentials) => { /* ... */ },
}),
{ name: 'auth-storage' }
)
)
// Persists loading state, errors, and sensitive tokensCorrect (selective persistence):
import { persist } from 'zustand/middleware'
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isLoading: false,
error: null,
loginAttempts: 0,
login: (credentials) => { /* ... */ },
}),
{
name: 'auth-storage',
// Only persist user profile, not tokens or UI state
partialize: (state) => ({
user: state.user,
}),
}
)
)Alternative (exclude specific fields):
import { persist } from 'zustand/middleware'
const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'dark',
language: 'en',
fontSize: 14,
sidebarOpen: true, // Don't persist
modalOpen: false, // Don't persist
}),
{
name: 'settings-storage',
partialize: (state) => {
// Exclude transient UI state
const { sidebarOpen, modalOpen, ...persisted } = state
return persisted
},
}
)
)What NOT to persist:
- Loading/error states
- Sensitive tokens (use secure storage instead)
- Temporary UI state (modals, tooltips)
- Derived/computed values
Reference: Zustand - Persist Middleware
Apply Middleware at Combined Store Level
When using the slices pattern, apply middleware to the combined store, not individual slices. This ensures middleware works consistently across all slices.
Incorrect (middleware on individual slices):
import { devtools, persist } from 'zustand/middleware'
// Wrong: applying middleware to slice
const createBearSlice = (set) =>
devtools((innerSet) => ({
bears: 0,
addBear: () => innerSet((s) => ({ bears: s.bears + 1 })),
}))(set)
const createFishSlice = (set) =>
persist((innerSet) => ({
fish: 0,
addFish: () => innerSet((s) => ({ fish: s.fish + 1 })),
}), { name: 'fish' })(set)
// Middleware behavior is inconsistent and may conflictCorrect (middleware at combined store level):
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
// Slices are plain state creators
const createBearSlice = (set, get) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
})
const createFishSlice = (set, get) => ({
fish: 0,
addFish: () => set((s) => ({ fish: s.fish + 1 })),
})
// Apply middleware when combining
const useBoundStore = create(
devtools(
persist(
(...args) => ({
...createBearSlice(...args),
...createFishSlice(...args),
}),
{
name: 'bound-store',
partialize: (state) => ({
bears: state.bears,
fish: state.fish,
}),
}
),
{ name: 'BoundStore' }
)
)TypeScript version with proper typing:
import { StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
type BoundStore = BearSlice & FishSlice
const createBearSlice: StateCreator<BoundStore, [], [], BearSlice> = (set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
})
const createFishSlice: StateCreator<BoundStore, [], [], FishSlice> = (set) => ({
fish: 0,
addFish: () => set((s) => ({ fish: s.fish + 1 })),
})Reference: Zustand - Slices Pattern
Avoid Returning New Objects from Selectors
Selectors that return new object literals trigger re-renders on every store update because {} !== {}. Return primitives or use useShallow for object selections.
Incorrect (new object on every call):
function UserBadge() {
// Creates new object literal on every store update
const user = useUserStore((state) => ({
displayName: `${state.firstName} ${state.lastName}`,
initials: `${state.firstName[0]}${state.lastName[0]}`,
}))
// Re-renders on ANY state change
return <Badge name={user.displayName} initials={user.initials} />
}Correct (separate atomic selectors):
function UserBadge() {
const firstName = useUserStore((s) => s.firstName)
const lastName = useUserStore((s) => s.lastName)
// Compute derived values in component
const displayName = `${firstName} ${lastName}`
const initials = `${firstName[0]}${lastName[0]}`
return <Badge name={displayName} initials={initials} />
}Alternative (useShallow for object returns):
import { useShallow } from 'zustand/react/shallow'
function UserBadge() {
const { firstName, lastName } = useUserStore(
useShallow((state) => ({
firstName: state.firstName,
lastName: state.lastName,
}))
)
const displayName = `${firstName} ${lastName}`
const initials = `${firstName[0]}${lastName[0]}`
return <Badge name={displayName} initials={initials} />
}Alternative (memoized selector hook):
const useUserBadgeData = () => {
const firstName = useUserStore((s) => s.firstName)
const lastName = useUserStore((s) => s.lastName)
return useMemo(() => ({
displayName: `${firstName} ${lastName}`,
initials: `${firstName[0]}${lastName[0]}`,
}), [firstName, lastName])
}Reference: Zustand Documentation
Provide Custom Equality Functions When Needed
For complex comparison logic beyond shallow equality, provide a custom equality function as the second argument to the store hook.
Incorrect (no equality function, always re-renders):
function ExpensiveChart() {
// Deep object, strict equality always fails
const chartData = useAnalyticsStore((state) => ({
labels: state.labels,
datasets: state.datasets.map((d) => ({
data: d.values,
label: d.name,
})),
}))
// Re-renders on every state change
return <Chart data={chartData} />
}Correct (custom equality function):
import { shallow } from 'zustand/shallow'
function ExpensiveChart() {
const chartData = useAnalyticsStore(
(state) => ({
labels: state.labels,
datasets: state.datasets.map((d) => ({
data: d.values,
label: d.name,
})),
}),
// Custom comparison: only re-render if labels or dataset count changes
(oldData, newData) =>
shallow(oldData.labels, newData.labels) &&
oldData.datasets.length === newData.datasets.length &&
oldData.datasets.every(
(d, i) => shallow(d.data, newData.datasets[i].data)
)
)
return <Chart data={chartData} />
}Alternative (deep equality with lodash):
import isEqual from 'lodash/isEqual'
function ExpensiveChart() {
const chartData = useAnalyticsStore(
(state) => ({
labels: state.labels,
datasets: state.datasets,
}),
isEqual // Deep equality check
)
return <Chart data={chartData} />
}When to use custom equality:
- Deep nested objects where shallow comparison isn't enough
- Performance-critical components with complex state
- When you need to ignore certain property changes
Reference: Zustand Documentation
Memo Children Affected by Parent Store Updates
When a parent component subscribes to the store, all children re-render by default. Use React.memo on expensive children that don't need the updated state.
Incorrect (children re-render with parent):
function Dashboard() {
const notifications = useNotificationStore((s) => s.notifications)
// Every notification update re-renders entire dashboard including children
return (
<div>
<NotificationBadge count={notifications.length} />
<ExpensiveChart /> {/* Re-renders unnecessarily */}
<ExpensiveTable /> {/* Re-renders unnecessarily */}
<ExpensiveCalendar /> {/* Re-renders unnecessarily */}
</div>
)
}Correct (memoized children):
const ExpensiveChart = memo(function ExpensiveChart() {
// Has its own store subscription
const chartData = useChartStore((s) => s.data)
return <Chart data={chartData} />
})
const ExpensiveTable = memo(function ExpensiveTable() {
const tableData = useTableStore((s) => s.rows)
return <Table rows={tableData} />
})
const ExpensiveCalendar = memo(function ExpensiveCalendar() {
const events = useCalendarStore((s) => s.events)
return <Calendar events={events} />
})
function Dashboard() {
const notifications = useNotificationStore((s) => s.notifications)
// Children only re-render when their own subscriptions change
return (
<div>
<NotificationBadge count={notifications.length} />
<ExpensiveChart />
<ExpensiveTable />
<ExpensiveCalendar />
</div>
)
}Note: React re-renders children by default. This is fine for most components. Only use memo when:
- The child is expensive to render
- The child doesn't need the parent's updated state
- Profiling shows the re-renders are a bottleneck
Reference: Zustand GitHub Discussions
Split Components to Minimize Subscription Scope
Extract store-subscribed code into smaller components. This isolates re-renders to only the components that need the updated state, leaving siblings and parents unaffected.
Incorrect (large component subscribes to multiple state slices):
function ProductPage() {
// All subscriptions in one component
const product = useProductStore((s) => s.currentProduct)
const reviews = useProductStore((s) => s.reviews)
const relatedProducts = useProductStore((s) => s.relatedProducts)
const cartCount = useCartStore((s) => s.items.length)
// Entire page re-renders when any of these change
return (
<div className="product-page">
<Header cartCount={cartCount} />
<ProductDetails product={product} />
<ReviewList reviews={reviews} />
<RelatedProducts products={relatedProducts} />
</div>
)
}Correct (split into focused components):
function CartBadge() {
const cartCount = useCartStore((s) => s.items.length)
return <span className="cart-badge">{cartCount}</span>
}
function ProductDetails() {
const product = useProductStore((s) => s.currentProduct)
return <div className="product-details">{/* ... */}</div>
}
function ReviewList() {
const reviews = useProductStore((s) => s.reviews)
return <div className="reviews">{/* ... */}</div>
}
function RelatedProducts() {
const relatedProducts = useProductStore((s) => s.relatedProducts)
return <div className="related">{/* ... */}</div>
}
function ProductPage() {
// Parent has no store subscriptions
return (
<div className="product-page">
<Header badge={<CartBadge />} />
<ProductDetails />
<ReviewList />
<RelatedProducts />
</div>
)
}Benefits:
- Cart changes only re-render
CartBadge - Review changes only re-render
ReviewList - Parent
ProductPagenever re-renders from store updates - Each component is easier to test and reason about
Reference: Working with Zustand - TkDodo
Use subscribe for Non-React Consumers
For non-React code or when you need to update the DOM directly without triggering React re-renders, use the store's subscribe method. This is useful for animations, canvas updates, or integrating with third-party libraries.
Incorrect (React re-render for every frame):
function AnimatedCounter() {
const progress = useProgressStore((s) => s.progress)
// Re-renders 60 times per second during animation
return (
<div
className="progress-bar"
style={{ width: `${progress}%` }}
/>
)
}Correct (direct DOM update via subscribe):
function AnimatedCounter() {
const progressRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// Subscribe directly to store updates
const unsubscribe = useProgressStore.subscribe(
(state) => state.progress,
(progress) => {
// Update DOM directly, no React re-render
if (progressRef.current) {
progressRef.current.style.width = `${progress}%`
}
}
)
return unsubscribe
}, [])
return <div ref={progressRef} className="progress-bar" />
}Alternative (subscribeWithSelector middleware):
import { subscribeWithSelector } from 'zustand/middleware'
const useProgressStore = create<ProgressState>()(
subscribeWithSelector((set) => ({
progress: 0,
setProgress: (progress) => set({ progress }),
}))
)
// Subscribe with fireImmediately option
useProgressStore.subscribe(
(state) => state.progress,
(progress, previousProgress) => {
console.log('Progress changed from', previousProgress, 'to', progress)
},
{ fireImmediately: true }
)When to use:
- High-frequency updates (animations, real-time data)
- Canvas or WebGL rendering
- Third-party library integration
- Analytics or logging
Reference: Zustand - subscribeWithSelector
Use useShallow for Multi-Property Selections
When selecting multiple properties as an object, wrap your selector with useShallow to use shallow comparison instead of strict equality. This prevents re-renders when the selected values haven't actually changed.
Incorrect (strict equality on object):
function UserCard() {
// Creates new object on every store update
const { name, email, avatar } = useUserStore((state) => ({
name: state.name,
email: state.email,
avatar: state.avatar,
}))
// Re-renders when ANY state changes because {} !== {}
return (
<div>
<img src={avatar} alt={name} />
<p>{name}</p>
<p>{email}</p>
</div>
)
}Correct (shallow comparison with useShallow):
import { useShallow } from 'zustand/react/shallow'
function UserCard() {
const { name, email, avatar } = useUserStore(
useShallow((state) => ({
name: state.name,
email: state.email,
avatar: state.avatar,
}))
)
// Only re-renders when name, email, or avatar actually changes
return (
<div>
<img src={avatar} alt={name} />
<p>{name}</p>
<p>{email}</p>
</div>
)
}Alternative (array selection with useShallow):
function BearStats() {
// Works with arrays too
const [bears, fish, honey] = useStore(
useShallow((state) => [state.bears, state.fish, state.honey])
)
return <div>Bears: {bears}, Fish: {fish}, Honey: {honey}</div>
}When to use:
- Selecting 2+ properties that you need as destructured values
- Selectors that return arrays (filter, map results)
- Any selector that creates a new reference
Reference: Zustand - Prevent Rerenders with useShallow
Always Use Selectors Never Subscribe to Entire Store
Never call the store hook without a selector. Subscribing to the entire store causes your component to re-render on any state change, even if the values you use didn't change.
Incorrect (subscribes to entire store):
const useBearStore = create<BearState>((set) => ({
bears: 0,
fish: 0,
honey: 100,
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
}))
function BearCounter() {
// Subscribes to entire store
const { bears } = useBearStore()
// Re-renders when fish or honey changes too!
return <div>Bears: {bears}</div>
}Correct (uses selector):
function BearCounter() {
// Subscribes only to bears
const bears = useBearStore((state) => state.bears)
// Only re-renders when bears changes
return <div>Bears: {bears}</div>
}Alternative (destructuring with useShallow):
import { useShallow } from 'zustand/react/shallow'
function BearStats() {
// Shallow comparison prevents re-renders when other state changes
const { bears, honey } = useBearStore(
useShallow((state) => ({ bears: state.bears, honey: state.honey }))
)
return <div>Bears: {bears}, Honey: {honey}</div>
}Benefits:
- Components only re-render when selected state changes
- More predictable performance
- Easier to optimize specific components
Reference: Zustand Documentation
Use Atomic Selectors for Single Values
Zustand uses strict equality (===) by default. Atomic selectors that return single primitive values are the most efficient because equality checks are instant and precise.
Incorrect (returns object, always re-renders):
function UserProfile() {
// Creates new object on every store update
const user = useBearStore((state) => ({
name: state.userName,
email: state.userEmail,
}))
// Re-renders on ANY state change because {} !== {}
return <div>{user.name} - {user.email}</div>
}Correct (atomic selectors):
function UserProfile() {
// Each selector returns a primitive
const name = useBearStore((state) => state.userName)
const email = useBearStore((state) => state.userEmail)
// Only re-renders when name or email actually changes
return <div>{name} - {email}</div>
}Alternative (multiple values with useShallow):
import { useShallow } from 'zustand/react/shallow'
function UserProfile() {
const { userName, userEmail } = useBearStore(
useShallow((state) => ({
userName: state.userName,
userEmail: state.userEmail,
}))
)
return <div>{userName} - {userEmail}</div>
}When to use each approach:
- Atomic selectors: Default choice, best performance
- useShallow: When you need 3+ values and want cleaner code
Reference: Working with Zustand - TkDodo
Use Auto-Generated Selectors for Large Stores
For stores with many properties, writing individual selector hooks is tedious. Use a utility to auto-generate selectors for each property.
Incorrect (manual selectors for each property):
const useSettingsStore = create<SettingsState>((set) => ({
theme: 'light',
language: 'en',
notifications: true,
fontSize: 14,
autoSave: true,
// ... 20 more settings
}))
// Tedious to write for each property
export const useTheme = () => useSettingsStore((s) => s.theme)
export const useLanguage = () => useSettingsStore((s) => s.language)
export const useNotifications = () => useSettingsStore((s) => s.notifications)
// ... 20 more hooksCorrect (auto-generate selectors):
import { StoreApi, UseBoundStore } from 'zustand'
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
store: S
) => {
const storeIn = store as WithSelectors<typeof store>
storeIn.use = {}
for (const key of Object.keys(storeIn.getState())) {
(storeIn.use as Record<string, () => unknown>)[key] = () =>
storeIn((s) => s[key as keyof typeof s])
}
return storeIn
}
// Create store with auto-generated selectors
const useSettingsStoreBase = create<SettingsState>((set) => ({
theme: 'light',
language: 'en',
notifications: true,
fontSize: 14,
autoSave: true,
}))
export const useSettingsStore = createSelectors(useSettingsStoreBase)
// Usage - type-safe auto-generated hooks
const theme = useSettingsStore.use.theme()
const language = useSettingsStore.use.language()Benefits:
- No manual selector boilerplate
- Type-safe generated hooks
- Consistent patterns across the codebase
Reference: Zustand - Auto Generating Selectors
Define Selectors Outside Components
Define selector functions outside components or memoize them. Inline selectors are recreated on every render, which can cause subtle performance issues with complex selectors.
Incorrect (inline selector recreated every render):
function ProductList() {
// This selector function is recreated on every render
const expensiveProducts = useProductStore((state) =>
state.products
.filter((p) => p.price > 100)
.sort((a, b) => b.price - a.price)
.slice(0, 10)
)
return <ProductGrid products={expensiveProducts} />
}Correct (selector defined outside):
// Selector defined once, reused
const selectExpensiveProducts = (state: ProductState) =>
state.products
.filter((p) => p.price > 100)
.sort((a, b) => b.price - a.price)
.slice(0, 10)
function ProductList() {
// Same function reference on every render
const expensiveProducts = useProductStore(
useShallow(selectExpensiveProducts)
)
return <ProductGrid products={expensiveProducts} />
}Alternative (custom hook encapsulation):
// Encapsulate in custom hook
const useExpensiveProducts = () => {
return useProductStore(
useShallow((state) =>
state.products
.filter((p) => p.price > 100)
.sort((a, b) => b.price - a.price)
.slice(0, 10)
)
)
}
function ProductList() {
const expensiveProducts = useExpensiveProducts()
return <ProductGrid products={expensiveProducts} />
}Note: For simple atomic selectors like (s) => s.count, inline is fine because the cost is negligible. This matters most for selectors that:
- Transform data (filter, map, sort)
- Create new objects or arrays
- Perform expensive computations
Reference: Zustand Documentation
Export Custom Hooks Not Raw Store
Wrap store access in custom hooks instead of exporting the raw store. This prevents accidental subscriptions to the entire store and provides a cleaner, more maintainable API.
Incorrect (exports raw store):
// store.ts
export const useBearStore = create<BearState>((set) => ({
bears: 0,
fish: 10,
honey: 100,
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
eatFish: () => set((s) => ({ fish: s.fish - 1 })),
}))
// component.tsx
import { useBearStore } from './store'
function BearCounter() {
// Easy to forget selector, subscribes to everything
const { bears } = useBearStore()
return <div>{bears}</div>
}Correct (exports custom hooks):
// store.ts
const useBearStore = create<BearState>((set) => ({
bears: 0,
fish: 10,
honey: 100,
actions: {
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
eatFish: () => set((s) => ({ fish: s.fish - 1 })),
},
}))
// Export only custom hooks
export const useBears = () => useBearStore((s) => s.bears)
export const useFish = () => useBearStore((s) => s.fish)
export const useHoney = () => useBearStore((s) => s.honey)
export const useBearActions = () => useBearStore((s) => s.actions)
// component.tsx
import { useBears, useBearActions } from './store'
function BearCounter() {
// Cannot accidentally subscribe to entire store
const bears = useBears()
const { increasePopulation } = useBearActions()
return <button onClick={increasePopulation}>{bears}</button>
}Benefits:
- Impossible to accidentally subscribe to entire store
- Selectors defined once, reused everywhere
- Easy to add memoization or logging later
- Store implementation is encapsulated
Reference: Working with Zustand - TkDodo
Memoize Expensive Computed Selectors
When selectors perform expensive computations, memoize the result to avoid recalculating on every render. Combine useMemo with atomic selectors for optimal performance.
Incorrect (expensive computation on every render):
function OrderSummary() {
const orders = useOrderStore((s) => s.orders)
// Recalculates on EVERY render, even if orders unchanged
const stats = {
total: orders.reduce((sum, o) => sum + o.total, 0),
count: orders.length,
average: orders.length > 0
? orders.reduce((sum, o) => sum + o.total, 0) / orders.length
: 0,
byStatus: orders.reduce((acc, o) => {
acc[o.status] = (acc[o.status] || 0) + 1
return acc
}, {} as Record<string, number>),
}
return <StatsDisplay stats={stats} />
}Correct (memoized computation):
function OrderSummary() {
const orders = useOrderStore((s) => s.orders)
// Only recalculates when orders array changes
const stats = useMemo(() => ({
total: orders.reduce((sum, o) => sum + o.total, 0),
count: orders.length,
average: orders.length > 0
? orders.reduce((sum, o) => sum + o.total, 0) / orders.length
: 0,
byStatus: orders.reduce((acc, o) => {
acc[o.status] = (acc[o.status] || 0) + 1
return acc
}, {} as Record<string, number>),
}), [orders])
return <StatsDisplay stats={stats} />
}Alternative (reusable computed selector hook):
const useOrderStats = () => {
const orders = useOrderStore((s) => s.orders)
return useMemo(() => ({
total: orders.reduce((sum, o) => sum + o.total, 0),
count: orders.length,
average: orders.length > 0
? orders.reduce((sum, o) => sum + o.total, 0) / orders.length
: 0,
}), [orders])
}
// Multiple components can reuse
function OrderSummary() {
const stats = useOrderStats()
return <StatsDisplay stats={stats} />
}Benefits:
- Expensive computations only run when dependencies change
- React's useMemo integrates naturally with Zustand selectors
- Reusable computed hooks share the pattern
Reference: Zustand Documentation
Ensure Selectors Return Stable References
Selectors that return new arrays or objects on every call trigger re-renders even when the content is identical. Zustand's strict equality check sees [] !== [] and {} !== {}.
Incorrect (creates new array on every call):
function ActiveUsers() {
// .filter() creates new array every time
const activeUsers = useUserStore((state) =>
state.users.filter((u) => u.isActive)
)
// Re-renders on ANY store change, not just when active users change
return <UserList users={activeUsers} />
}Correct (use useShallow for derived arrays):
import { useShallow } from 'zustand/react/shallow'
function ActiveUsers() {
// useShallow compares array contents
const activeUsers = useUserStore(
useShallow((state) => state.users.filter((u) => u.isActive))
)
// Only re-renders when the filtered result actually changes
return <UserList users={activeUsers} />
}Alternative (memoize in component):
function ActiveUsers() {
const users = useUserStore((state) => state.users)
// Memoize derived value in component
const activeUsers = useMemo(
() => users.filter((u) => u.isActive),
[users]
)
return <UserList users={activeUsers} />
}Alternative (store the filtered list):
// If filtering is expensive or used in many components
const useActiveUsers = () => useUserStore(
useShallow((state) => state.users.filter((u) => u.isActive))
)Reference: Zustand - Prevent Rerenders with useShallow
Guard Browser APIs with typeof window Check
When stores use browser-only APIs like localStorage, window, or document, guard them with typeof window !== 'undefined' to prevent crashes during server-side rendering.
Incorrect (crashes on server):
const useThemeStore = create<ThemeState>((set) => ({
// Crashes on server: localStorage is not defined
theme: localStorage.getItem('theme') || 'light',
setTheme: (theme) => {
localStorage.setItem('theme', theme)
set({ theme })
},
}))Correct (guarded browser API access):
const getInitialTheme = (): Theme => {
if (typeof window === 'undefined') {
return 'light' // SSR default
}
return (localStorage.getItem('theme') as Theme) || 'light'
}
const useThemeStore = create<ThemeState>((set) => ({
theme: getInitialTheme(),
setTheme: (theme) => {
if (typeof window !== 'undefined') {
localStorage.setItem('theme', theme)
}
set({ theme })
},
}))Alternative (use persist middleware which handles this):
import { persist, createJSONStorage } from 'zustand/middleware'
const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
// createJSONStorage handles SSR safely
storage: createJSONStorage(() => localStorage),
skipHydration: true,
}
)
)Common browser APIs to guard:
localStorage/sessionStoragewindow.matchMedia()document.cookienavigator.userAgentwindow.innerWidth/innerHeight
Reference: Next.js Documentation
Use Custom Hook to Prevent Hydration Mismatch
Create a custom hook that delays returning the store value until after hydration. This ensures components render with initial state during SSR and hydration, then update with persisted state.
Incorrect (direct store access causes mismatch):
function ThemeSwitcher() {
// On server: 'light' (initial)
// On client during hydration: 'dark' (from localStorage)
// Mismatch error!
const theme = useThemeStore((s) => s.theme)
return <span>Current theme: {theme}</span>
}Correct (delayed hydration hook):
// hooks/useHydratedStore.ts
import { useState, useEffect } from 'react'
export function useHydratedStore<T, F>(
store: (callback: (state: T) => F) => F,
selector: (state: T) => F
): F | undefined {
const storeValue = store(selector)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
// Return undefined during SSR and initial hydration
// Return actual value after client mount
return hydrated ? storeValue : undefined
}
// Usage
function ThemeSwitcher() {
const theme = useHydratedStore(useThemeStore, (s) => s.theme)
// Render fallback during hydration
if (theme === undefined) {
return <span>Loading theme...</span>
}
return <span>Current theme: {theme}</span>
}Alternative (return initial value during SSR):
export function useHydratedStore<T, F>(
store: (callback: (state: T) => F) => F,
selector: (state: T) => F,
initialValue: F
): F {
const storeValue = store(selector)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
return hydrated ? storeValue : initialValue
}
// Usage - provides consistent initial value
function ThemeSwitcher() {
const theme = useHydratedStore(useThemeStore, (s) => s.theme, 'light')
// Always renders 'light' on server and during hydration
return <span>Current theme: {theme}</span>
}Reference: Zustand - Persisting Store Data
Manually Rehydrate on Client Mount
After disabling automatic hydration with skipHydration, trigger rehydration manually in a useEffect after the component mounts. This ensures server and client render the same initial content.
Incorrect (no manual rehydration):
const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
skipHydration: true,
}
)
)
// Store never loads persisted state
// User always sees 'light' theme on page loadCorrect (manual rehydration in layout/app):
// stores/theme.ts
const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
skipHydration: true,
}
)
)
// app/layout.tsx or _app.tsx
'use client'
import { useEffect } from 'react'
import { useThemeStore } from '@/stores/theme'
function HydrationProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Rehydrate after client mount
useThemeStore.persist.rehydrate()
}, [])
return <>{children}</>
}Alternative (rehydrate multiple stores):
'use client'
import { useEffect } from 'react'
import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings'
import { useUserStore } from '@/stores/user'
export function StoreHydration() {
useEffect(() => {
// Rehydrate all persisted stores
useThemeStore.persist.rehydrate()
useSettingsStore.persist.rehydrate()
useUserStore.persist.rehydrate()
}, [])
return null
}
// In layout
export default function RootLayout({ children }) {
return (
<html>
<body>
<StoreHydration />
{children}
</body>
</html>
)
}Reference: Zustand - Persist Middleware
Use skipHydration in SSR Contexts
In SSR environments like Next.js, persisted stores can cause hydration mismatches because the server renders with initial state while the client hydrates with persisted state. Use skipHydration: true to defer hydration.
Incorrect (automatic hydration causes mismatch):
import { persist } from 'zustand/middleware'
const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'theme-storage' }
)
)
// Server renders: theme='light'
// Client hydrates with localStorage: theme='dark'
// Error: "Text content does not match server-rendered HTML"Correct (skip automatic hydration):
import { persist } from 'zustand/middleware'
const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
skipHydration: true, // Disable automatic hydration
}
)
)
// Manually trigger hydration after client mount
// See ssr-manual-rehydrate ruleWhen to use:
- Any persisted store in Next.js/Remix/SSR apps
- Stores that affect server-rendered content
- Stores where initial state differs from persisted state
When NOT needed:
- Client-only apps (CRA, Vite without SSR)
- Stores that don't use persist middleware
- Persisted values not rendered on initial page load
Reference: Zustand - Persist Middleware
Derive Computed Values Instead of Storing Them
Don't store values that can be computed from other state. Derived state creates synchronization problems and increases the chance of bugs. Use selectors to compute values on demand.
Incorrect (storing derived state):
const useCartStore = create<CartState>((set) => ({
items: [],
itemCount: 0, // Derived from items.length
subtotal: 0, // Derived from items
tax: 0, // Derived from subtotal
total: 0, // Derived from subtotal + tax
addItem: (item) => set((s) => {
const newItems = [...s.items, item]
const subtotal = newItems.reduce((sum, i) => sum + i.price, 0)
const tax = subtotal * 0.1
// Must update 5 values, easy to miss one
return {
items: newItems,
itemCount: newItems.length,
subtotal,
tax,
total: subtotal + tax,
}
}),
}))Correct (compute values via selectors):
const useCartStore = create<CartState>((set) => ({
items: [],
taxRate: 0.1,
addItem: (item) => set((s) => ({
items: [...s.items, item],
})),
}))
// Derive values with selectors
const useItemCount = () => useCartStore((s) => s.items.length)
const useSubtotal = () => useCartStore((s) =>
s.items.reduce((sum, item) => sum + item.price, 0)
)
const useTotal = () => useCartStore((s) => {
const subtotal = s.items.reduce((sum, item) => sum + item.price, 0)
return subtotal + subtotal * s.taxRate
})When NOT to use this pattern:
- Expensive computations that need memoization (use
useMemooutside the store) - Values needed for external subscriptions (React Query keys, etc.)
Benefits:
- Single source of truth
- No synchronization bugs
- Simpler actions
Reference: Working with Zustand - TkDodo
Colocate Actions with the State They Modify
Keep actions and the state they modify within the same store. This promotes encapsulation and makes it easier to understand how state is updated. Avoid cross-store mutations.
Incorrect (cross-store mutations):
const useUserStore = create<UserState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
const useCartStore = create<CartState>((set) => ({
items: [],
userId: null,
// Anti-pattern: modifying user store from cart store
addItemAndUpdateUser: (item) => {
set((s) => ({ items: [...s.items, item] }))
useUserStore.getState().setUser({
...useUserStore.getState().user,
lastActivity: Date.now(),
})
},
}))Correct (actions modify own state only):
const useUserStore = create<UserState>((set) => ({
user: null,
setUser: (user) => set({ user }),
updateLastActivity: () => set((s) => ({
user: s.user ? { ...s.user, lastActivity: Date.now() } : null,
})),
}))
const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}))
// Coordinate in component or custom hook
const useAddToCartWithActivity = () => {
const addItem = useCartStore((s) => s.addItem)
const updateLastActivity = useUserStore((s) => s.updateLastActivity)
return (item: CartItem) => {
addItem(item)
updateLastActivity()
}
}Benefits:
- Each store is self-contained and testable
- Clear ownership of state mutations
- Easier to trace state changes
Reference: Zustand Documentation
Organize Stores by Feature Domain
Structure stores around feature domains rather than data types. This aligns with how teams work on features and enables better code splitting.
Incorrect (organized by data type):
// stores/entities.ts - all entities in one place
const useEntitiesStore = create((set) => ({
users: [],
products: [],
orders: [],
// Hard to find what belongs where
}))
// stores/ui.ts - all UI state together
const useUIStore = create((set) => ({
userModalOpen: false,
productFilterVisible: false,
checkoutStep: 0,
// Unrelated concerns mixed
}))Correct (organized by feature domain):
// features/auth/store.ts
const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
login: (credentials) => { /* ... */ },
logout: () => set({ user: null, isAuthenticated: false }),
}))
// features/catalog/store.ts
const useCatalogStore = create<CatalogState>((set) => ({
products: [],
filters: { category: null, priceRange: null },
filterVisible: false,
setFilters: (filters) => set({ filters }),
toggleFilterVisible: () => set((s) => ({ filterVisible: !s.filterVisible })),
}))
// features/checkout/store.ts
const useCheckoutStore = create<CheckoutState>((set) => ({
step: 0,
shippingAddress: null,
paymentMethod: null,
nextStep: () => set((s) => ({ step: s.step + 1 })),
}))Benefits:
- Feature teams own their stores
- Easy to code-split by feature
- Related state and actions colocated
- Clear boundaries between domains
Reference: Zustand Documentation - Slices Pattern
Name Actions as Events Not Setters
Name actions descriptively to represent what happened (events), not what state changed (setters). This encapsulates business logic within the store and makes the API more expressive.
Incorrect (setter-style names):
const useCartStore = create<CartState>((set) => ({
items: [],
total: 0,
// Setter names expose implementation details
setItems: (items) => set({ items }),
setTotal: (total) => set({ total }),
// Component must calculate business logic
addItem: (item) => set((s) => ({
items: [...s.items, item],
total: s.total + item.price,
})),
}))
// Component handles logic
const handleAddToCart = (product) => {
if (product.stock > 0) {
addItem({ ...product, quantity: 1 })
}
}Correct (event-style names):
const useCartStore = create<CartState>((set, get) => ({
items: [],
total: 0,
// Event names describe what happened
itemAddedToCart: (product: Product) => {
if (product.stock <= 0) return
set((s) => ({
items: [...s.items, { ...product, quantity: 1 }],
total: s.total + product.price,
}))
},
itemRemovedFromCart: (productId: string) => {
const item = get().items.find((i) => i.id === productId)
if (!item) return
set((s) => ({
items: s.items.filter((i) => i.id !== productId),
total: s.total - item.price * item.quantity,
}))
},
cartCleared: () => set({ items: [], total: 0 }),
}))
// Component is simple
const handleAddToCart = (product) => {
itemAddedToCart(product)
}Benefits:
- Business logic encapsulated in store
- Components become thinner and easier to test
- Action names are self-documenting
Reference: Redux Style Guide - Model Actions as Events
Use Multiple Small Stores Instead of One Monolithic Store
Unlike Redux, Zustand encourages multiple small stores instead of a single global store. Each store handles one domain, reducing subscription scope and preventing unrelated state changes from triggering re-renders.
Incorrect (monolithic store):
const useStore = create<AppState>((set) => ({
// User domain
user: null,
setUser: (user) => set({ user }),
// Cart domain
cartItems: [],
addToCart: (item) => set((s) => ({ cartItems: [...s.cartItems, item] })),
// UI domain
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
// Theme domain
theme: 'light',
setTheme: (theme) => set({ theme }),
}))
// Any state change triggers re-renders in all subscribed componentsCorrect (domain-specific stores):
const useUserStore = create<UserState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
const useCartStore = create<CartState>((set) => ({
cartItems: [],
addToCart: (item) => set((s) => ({ cartItems: [...s.cartItems, item] })),
}))
const useUIStore = create<UIState>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}))
const useThemeStore = create<ThemeState>((set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}))
// Cart changes only affect components subscribed to useCartStoreBenefits:
- Smaller subscription scope means fewer unnecessary re-renders
- Easier to test and maintain isolated domains
- Better code splitting potential
Reference: Working with Zustand - TkDodo
Separate Actions from State in Dedicated Namespace
Group all mutations into a dedicated actions namespace. This provides a single stable hook for all actions without re-render concerns, since actions never change after store creation.
Incorrect (actions mixed with state):
const useBearStore = create<BearState>((set, get) => ({
bears: 0,
honey: 100,
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
decreasePopulation: () => set((s) => ({ bears: s.bears - 1 })),
eatHoney: () => set((s) => ({ honey: s.honey - 10 })),
removeAllBears: () => set({ bears: 0 }),
}))
// Components must pick each action individually
const increase = useBearStore((s) => s.increasePopulation)
const decrease = useBearStore((s) => s.decreasePopulation)Correct (actions in dedicated namespace):
const useBearStore = create<BearState>((set, get) => ({
bears: 0,
honey: 100,
actions: {
increasePopulation: () => set((s) => ({ bears: s.bears + 1 })),
decreasePopulation: () => set((s) => ({ bears: s.bears - 1 })),
eatHoney: () => set((s) => ({ honey: s.honey - 10 })),
removeAllBears: () => set({ bears: 0 }),
},
}))
// Single hook for all actions, never causes re-renders
const useBearActions = () => useBearStore((s) => s.actions)
// Usage
const { increasePopulation, eatHoney } = useBearActions()Benefits:
- Actions object is stable, never triggers re-renders
- Cleaner API with single actions hook
- Business logic stays in store, not components
Reference: Working with Zustand - TkDodo
Type Combined Stores Correctly
When combining slices, ensure the combined store type includes all slices and properly types cross-slice interactions.
Incorrect (incomplete combined type):
// Missing slice in combined type
const useBoundStore = create<BearSlice & FishSlice>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createSharedSlice(...a), // SharedSlice not in type!
}))
// SharedSlice actions won't be typed correctly
const { addBoth } = useBoundStore() // Type errorCorrect (complete combined type):
import { create, StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
interface SharedSlice {
addBoth: () => void
getTotalAnimals: () => number
}
// Complete combined type
type BoundStore = BearSlice & FishSlice & SharedSlice
const createBearSlice: StateCreator<BoundStore, [], [], BearSlice> = (set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
})
const createFishSlice: StateCreator<BoundStore, [], [], FishSlice> = (set) => ({
fish: 0,
addFish: () => set((s) => ({ fish: s.fish + 1 })),
})
// SharedSlice can access all other slices type-safely
const createSharedSlice: StateCreator<BoundStore, [], [], SharedSlice> = (
set,
get
) => ({
addBoth: () => {
get().addBear()
get().addFish()
},
getTotalAnimals: () => get().bears + get().fish,
})
// Properly typed combined store
const useBoundStore = create<BoundStore>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createSharedSlice(...a),
}))
// All methods are properly typed
const { bears, fish, addBoth, getTotalAnimals } = useBoundStore()Benefits:
- Cross-slice method calls are type-checked
- IDE autocomplete works across all slices
- Compile-time errors catch missing slice implementations
Reference: Zustand - TypeScript Guide
Type Selectors for Reusability
Create typed selector factories and utilities for consistent, reusable selection patterns across your codebase.
Incorrect (untyped inline selectors):
// Each usage might have different typing
const bears = useStore((s) => s.bears) // s is inferred
const fish = useStore((s) => s.fish) // repeated pattern
// No shared selector logic
const totalAnimals = useStore((s) => s.bears + s.fish)Correct (typed selector utilities):
import { StoreApi, UseBoundStore } from 'zustand'
// Selector type
type Selector<S, R> = (state: S) => R
// Create typed selector function
function createSelector<S, R>(selector: Selector<S, R>): Selector<S, R> {
return selector
}
// Define selectors outside components
const selectBears = createSelector<BearStore, number>((s) => s.bears)
const selectFish = createSelector<BearStore, number>((s) => s.fish)
const selectTotalAnimals = createSelector<BearStore, number>(
(s) => s.bears + s.fish
)
// Usage
function BearCounter() {
const bears = useBearStore(selectBears)
const total = useBearStore(selectTotalAnimals)
return <div>{bears} of {total} animals</div>
}Alternative (selector factory with auto-generated hooks):
type CreateBoundSelector<S> = <R>(selector: (state: S) => R) => () => R
function createBoundSelectors<S>(
useStore: UseBoundStore<StoreApi<S>>
): CreateBoundSelector<S> {
return (selector) => () => useStore(selector)
}
const useBearStore = create<BearStore>()((set) => ({
bears: 0,
fish: 10,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
}))
const createSelector = createBoundSelectors(useBearStore)
// Type-safe selector hooks
const useBears = createSelector((s) => s.bears)
const useFish = createSelector((s) => s.fish)
const useTotal = createSelector((s) => s.bears + s.fish)
// Usage
function Stats() {
const total = useTotal()
return <span>Total: {total}</span>
}Reference: Zustand Documentation
Preserve Type Inference with Middleware
When combining middlewares, TypeScript inference can break. Use the double-parentheses pattern create<State>()() and explicitly type StateCreator for slices with middleware.
Incorrect (loses inference with middlewares):
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
// Type error or loses inference
const useStore = create<State>(
devtools(
persist(
(set) => ({
count: 0,
// set might be typed as 'any'
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{ name: 'store' }
)
)
)Correct (double parentheses pattern):
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface State {
count: number
increment: () => void
}
// Double parentheses enables proper inference
const useStore = create<State>()(
devtools(
persist(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{ name: 'store' }
)
)
)With slices and middleware:
import { StateCreator } from 'zustand'
import { devtools } from 'zustand/middleware'
interface BearSlice {
bears: number
addBear: () => void
}
// Specify devtools in middleware array
const createBearSlice: StateCreator<
BearSlice,
[['zustand/devtools', never]], // Middleware this slice expects
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set(
(s) => ({ bears: s.bears + 1 }),
undefined,
'bears/addBear' // Action name for devtools
),
})
const useBearStore = create<BearSlice>()(
devtools(createBearSlice)
)Reference: Zustand - TypeScript Guide
Separate State and Actions Interfaces
Define separate interfaces for state properties and actions. This improves readability, enables type reuse, and makes it easier to use partialize with persist.
Incorrect (mixed state and actions):
// Hard to separate what's state vs actions
interface BearStore {
bears: number
fish: number
honey: number
isHibernating: boolean
addBear: () => void
removeBear: () => void
eatFish: () => void
eatHoney: (amount: number) => void
startHibernation: () => void
endHibernation: () => void
}
// Can't easily partialize just state
persist(storeCreator, {
partialize: (state) => ({
bears: state.bears,
fish: state.fish,
// Must list each property manually
}),
})Correct (separated interfaces):
// State properties
interface BearState {
bears: number
fish: number
honey: number
isHibernating: boolean
}
// Actions
interface BearActions {
addBear: () => void
removeBear: () => void
eatFish: () => void
eatHoney: (amount: number) => void
startHibernation: () => void
endHibernation: () => void
}
// Combined store type
type BearStore = BearState & BearActions
// Now partialize is simple
persist(storeCreator, {
partialize: (state): BearState => ({
bears: state.bears,
fish: state.fish,
honey: state.honey,
isHibernating: state.isHibernating,
}),
})Alternative (with utility type for actions exclusion):
interface BearState {
bears: number
fish: number
}
interface BearActions {
addBear: () => void
eatFish: () => void
}
type BearStore = BearState & { actions: BearActions }
// Actions in namespace, easy to partialize
const useBearStore = create<BearStore>()(
persist(
(set) => ({
bears: 0,
fish: 10,
actions: {
addBear: () => set((s) => ({ bears: s.bears + 1 })),
eatFish: () => set((s) => ({ fish: s.fish - 1 })),
},
}),
{
name: 'bear-store',
// Exclude actions from persistence automatically
partialize: ({ actions, ...state }) => state,
}
)
)Reference: Zustand Documentation
Use StateCreator for Slice Typing
When using the slices pattern, use StateCreator type to properly type slice creators. This enables type-safe access to other slices and proper middleware typing.
Incorrect (loses type inference):
// Types are lost or require manual casting
const createBearSlice = (set, get) => ({
bears: 0,
// get() returns unknown, no type safety
eatFish: () => set({ bears: get().bears + 1 }),
})
const createFishSlice = (set, get) => ({
fish: 10,
// Can't access bears slice type-safely
feedBear: () => set({ fish: get().fish - 1 }),
})Correct (typed with StateCreator):
import { create, StateCreator } from 'zustand'
interface BearSlice {
bears: number
addBear: () => void
eatFish: () => void
}
interface FishSlice {
fish: number
addFish: () => void
}
type BoundStore = BearSlice & FishSlice
// StateCreator<FullStore, Middlewares, SliceMiddlewares, ThisSlice>
const createBearSlice: StateCreator<
BoundStore,
[],
[],
BearSlice
> = (set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
// Type-safe access to fish slice
eatFish: () => set((s) => ({
bears: s.bears,
fish: s.fish - 1,
})),
})
const createFishSlice: StateCreator<
BoundStore,
[],
[],
FishSlice
> = (set) => ({
fish: 10,
addFish: () => set((s) => ({ fish: s.fish + 1 })),
})
const useBoundStore = create<BoundStore>()((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))StateCreator type parameters: 1. Full store type (all slices combined) 2. Middleware types applied to store 3. Middleware types applied to this slice 4. This slice's type
Reference: Zustand - TypeScript Guide
Handle Async Actions with Loading and Error States
Async actions should track loading and error states in the store. This prevents race conditions and provides UI feedback. Use try/catch and set states at the right moments.
Incorrect (no loading or error handling):
const useUserStore = create<UserState>((set) => ({
user: null,
fetchUser: async (id: string) => {
// No loading state, UI doesn't know fetch is in progress
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
set({ user })
// Errors silently fail
},
}))Correct (proper loading and error states):
interface UserState {
user: User | null
isLoading: boolean
error: Error | null
fetchUser: (id: string) => Promise<void>
}
const useUserStore = create<UserState>((set) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (id: string) => {
set({ isLoading: true, error: null })
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`)
}
const user = await response.json()
set({ user, isLoading: false })
} catch (error) {
set({
error: error instanceof Error ? error : new Error('Unknown error'),
isLoading: false,
})
}
},
}))
// Usage in component
function UserProfile({ id }: { id: string }) {
const { user, isLoading, error, fetchUser } = useUserStore()
useEffect(() => {
fetchUser(id)
}, [id, fetchUser])
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
if (!user) return null
return <Profile user={user} />
}Alternative (abort previous requests):
const useUserStore = create<UserState>((set, get) => ({
user: null,
isLoading: false,
abortController: null as AbortController | null,
fetchUser: async (id: string) => {
// Abort any in-flight request
get().abortController?.abort()
const abortController = new AbortController()
set({ isLoading: true, abortController })
try {
const response = await fetch(`/api/users/${id}`, {
signal: abortController.signal,
})
const user = await response.json()
set({ user, isLoading: false })
} catch (error) {
if (error.name !== 'AbortError') {
set({ isLoading: false })
}
}
},
}))Reference: Zustand Documentation
Batch Related Updates in Single set Call
When multiple state properties need to change together, update them in a single set call. This ensures state consistency and triggers only one re-render.
Incorrect (multiple set calls):
const useFormStore = create<FormState>((set) => ({
values: {},
errors: {},
touched: {},
isSubmitting: false,
submitForm: async () => {
set({ isSubmitting: true }) // Re-render 1
set({ errors: {} }) // Re-render 2
try {
await submitToServer(get().values)
set({ isSubmitting: false }) // Re-render 3
} catch (error) {
set({ errors: parseErrors(error) }) // Re-render 4
set({ isSubmitting: false }) // Re-render 5
}
},
}))Correct (batched updates):
const useFormStore = create<FormState>((set, get) => ({
values: {},
errors: {},
touched: {},
isSubmitting: false,
submitForm: async () => {
// Single update for start
set({ isSubmitting: true, errors: {} })
try {
await submitToServer(get().values)
// Single update for success
set({ isSubmitting: false })
} catch (error) {
// Single update for failure
set({
errors: parseErrors(error),
isSubmitting: false,
})
}
},
}))Note on React 18: React 18 batches state updates automatically within event handlers and effects. However, batching in Zustand still matters for:
- State consistency within a single synchronous operation
- Reducing subscriber notifications
- Clear intent in code
When separate calls are acceptable:
// Separate updates when they're truly independent
set({ count: state.count + 1 })
// ... some async work
set({ lastUpdated: Date.now() })Reference: Zustand Documentation
Use Functional Form When Updating Based on Previous State
When updating state based on its current value, use the functional form of set. This ensures you always work with the latest state, avoiding stale closure bugs.
Incorrect (uses stale closure):
const useCounterStore = create<CounterState>((set, get) => ({
count: 0,
// Captures `count` in closure, may be stale
incrementBy: (amount) => set({ count: get().count + amount }),
// Multiple rapid calls may lose updates
incrementTwice: () => {
set({ count: get().count + 1 })
set({ count: get().count + 1 })
// May result in +1 instead of +2
},
}))Correct (functional updater):
const useCounterStore = create<CounterState>((set) => ({
count: 0,
// Always uses latest state
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
// Both updates apply correctly
incrementTwice: () => {
set((state) => ({ count: state.count + 1 }))
set((state) => ({ count: state.count + 1 }))
// Always results in +2
},
}))When to use each approach:
const useStore = create<State>((set) => ({
user: null,
count: 0,
// Direct set: when not depending on previous state
setUser: (user) => set({ user }),
// Functional set: when depending on previous state
increment: () => set((state) => ({ count: state.count + 1 })),
// Functional set: for conditional updates
decrementIfPositive: () => set((state) =>
state.count > 0 ? { count: state.count - 1 } : state
),
}))Reference: Zustand Documentation - Updating State
Never Mutate State Directly
Always create new objects/arrays when updating state. Direct mutation bypasses Zustand's change detection, causing components to not re-render and DevTools to miss updates.
Incorrect (mutates existing state):
const useListStore = create<ListState>((set, get) => ({
items: [],
addItem: (item) => {
// Mutates existing array
get().items.push(item)
set({ items: get().items })
// Same array reference, components may not re-render
},
updateItem: (id, updates) => {
const items = get().items
const item = items.find((i) => i.id === id)
// Mutates existing object
Object.assign(item, updates)
set({ items })
},
}))Correct (creates new references):
const useListStore = create<ListState>((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item], // New reference, triggers re-render
})),
updateItem: (id, updates) => set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, ...updates } : item
),
})),
removeItem: (id) => set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
}))Alternative (use immer for complex updates):
import { immer } from 'zustand/middleware/immer'
const useListStore = create<ListState>()(
immer((set) => ({
items: [],
// Immer allows mutation syntax, handles immutability internally
addItem: (item) => set((state) => {
state.items.push(item)
}),
updateItem: (id, updates) => set((state) => {
const item = state.items.find((i) => i.id === id)
if (item) Object.assign(item, updates)
}),
}))
)Reference: Zustand - Immer Middleware
Understand set() Shallow Merge Behavior
Zustand's set performs a shallow merge by default. It merges top-level properties but replaces nested objects entirely. Understand this to avoid accidental data loss.
Incorrect (expects deep merge):
const useUserStore = create<UserState>((set) => ({
user: {
name: 'John',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
},
// Expects to only update theme, but replaces entire preferences
updateTheme: (theme) => set({
user: {
preferences: { theme },
},
}),
// Result: user = { preferences: { theme: 'light' } }
// name, email, and notifications are lost!
}))Correct (preserve nested structure):
const useUserStore = create<UserState>((set) => ({
user: {
name: 'John',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
},
// Spread to preserve existing properties at each level
updateTheme: (theme) => set((state) => ({
user: {
...state.user,
preferences: {
...state.user.preferences,
theme,
},
},
})),
}))Alternative (flatten state structure):
// Flatter state is easier to update
const useUserStore = create<UserState>((set) => ({
userName: 'John',
userEmail: 'john@example.com',
prefTheme: 'dark',
prefNotifications: true,
// Simple top-level updates
updateTheme: (prefTheme) => set({ prefTheme }),
}))Alternative (use immer for deep updates):
import { immer } from 'zustand/middleware/immer'
const useUserStore = create<UserState>()(
immer((set) => ({
user: { name: 'John', preferences: { theme: 'dark' } },
updateTheme: (theme) => set((state) => {
state.user.preferences.theme = theme
}),
}))
)Reference: Zustand Documentation - Updating State
Related skills
How it compares
Pick a Zustand-focused helper when your codebase already uses Zustand; pick framework-agnostic state guidance when you are still choosing a state library.
FAQ
What problems does zustand help with?
zustand helps with implementing and refactoring Zustand-based state management in React applications. zustand is useful when developers need a consistent store structure, typed actions/selectors, and guidance for wiring store hooks into components without creating unnecessary re-
Is zustand focused on frontend or backend work?
zustand is focused on frontend work because Zustand is typically used for client-side React state. zustand is most relevant during UI implementation when developers need shared state across components such as filters, forms, or application settings.