⏱️ Estimated read: 4 min
|
|
Installation
npm install zustand
# or
pnpm add zustand
# or
yarn add zustand
| Package | Purpose |
|---|
zustand | Core store, hooks, middleware |
immer | Required for immer middleware |
zustand/middleware | Built-in middleware (persist, devtools, immer) |
| Requirement | Value |
|---|
| React version | 18.0+ |
| TypeScript syntax | create<T>()(...) (curried, v5) |
| Provider | Not required |
Creating a Store
import { create } from 'zustand'
interface CounterState {
count: number
increment: () => void
}
export const useCounterStore = create<CounterState>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
| Rule | Detail |
|---|
| Curried syntax | create<T>()(...) — double parentheses |
| Module scope | Store created once, outside components |
set behavior | Shallow merge |
get behavior | Read current state at call time |
| Export | The hook, not the store object |
Reading State
// Atomic selector: re-renders only when count changes
const count = useCounterStore((state) => state.count)
// Action selector: stable reference, never re-renders
const increment = useCounterStore((state) => state.increment)
// Multiple primitives: multiple hook calls
const count = useCounterStore((s) => s.count)
const name = useCounterStore((s) => s.name)
// Object selector: useShallow required in v5
import { useShallow } from 'zustand/react/shallow'
const { count, name } = useCounterStore(
useShallow((s) => ({ count: s.count, name: s.name }))
)
| Pattern | Re-renders When |
|---|
(s) => s.count | count changes |
(s) => s.action | Never |
(s) => ({ a, b }) without useShallow | Every store change (v5 crash) |
useShallow((s) => ({ a, b })) | a or b changes |
(s) => s | Every change |
Updating State
// Functional form: reads current state at update time
increment: () => set((state) => ({ count: state.count + 1 }))
// Direct object: constant value
reset: () => set({ count: 0 })
// Nested object: spread every level
setName: (name) => set((state) => ({ user: { ...state.user, name } }))
// Array: new reference
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] }))
// Array element: map + spread
toggle: (id) =>
set((state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
}))
| Operation | Pattern |
|---|
| Top-level update | set({ key: value }) |
| State-dependent | set((state) => ({ ... })) |
| Nested object | { ...state.obj, key: value } |
| Array add | [...state.items, newItem] |
| Array remove | state.items.filter(...) |
| Array update | state.items.map(...) |
| Reset | set(initialState) |
Middleware
import { persist, devtools } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
export const useStore = create<State>()(
devtools( // Outermost
persist( // Middle
immer((set) => ({ // Innermost
count: 0,
increment: () => set((state) => { state.count += 1 }, false, 'counter/increment'),
})),
{ name: 'counter-storage' }
),
{ name: 'CounterStore', enabled: process.env.NODE_ENV === 'development' }
)
)
| Middleware | Purpose | Key Options |
|---|
persist | localStorage/sessionStorage | name, partialize, version, migrate |
devtools | Redux DevTools | name, enabled |
immer | Mutating syntax | Requires immer package |
subscribeWithSelector | Selector-based subscribe | — |
| Order | Correct |
|---|
| Outermost | devtools |
| Middle | persist |
| Innermost | immer |
Persist
persist(
(set) => ({ theme: 'light', sidebarOpen: true }),
{
name: 'settings-storage',
partialize: (state) => ({ theme: state.theme }), // only persist theme
version: 1,
migrate: (persisted, version) => {
if (version === 0) return { theme: persisted.theme }
return persisted
},
}
)
| Option | Purpose |
|---|
name | localStorage key (required) |
partialize | Select fields to persist |
version | Schema version |
migrate | Transform old persisted state |
skipHydration | Defer hydration (SSR) |
Immer
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({ id: Date.now(), text }) // mutate draft
}),
toggle: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.done = !todo.done
}),
}))
| Rule | Status |
|---|
| Mutate draft, return nothing | ✅ Correct |
| Return new value, no mutation | ✅ Correct |
| Mutate and return | ❌ Immer error |
DevTools
increment: () =>
set((state) => ({ count: state.count + 1 }), false, 'counter/increment')
// │ │
// │ └── action name
// └── replace flag (false)
set Argument | Purpose |
|---|
| 1st | Partial state or updater |
| 2nd | Replace flag (usually false) |
| 3rd | Action name for DevTools |
Subscribe (Transient)
import { subscribeWithSelector } from 'zustand/middleware'
const useStore = create<State>()(
subscribeWithSelector((set) => ({ count: 0 }))
)
// In a component
useEffect(() => {
const unsub = useStore.subscribe(
(state) => state.count,
(count, prevCount) => console.log(count, prevCount)
)
return unsub
}, [])
| Method | Purpose |
|---|
store.subscribe(listener) | Fires on every change |
store.subscribe(selector, listener) | Fires when selector result changes |
store.getState() | Read without subscribing |
store.setState(partial) | Write without a component |
Testing
// Factory pattern: fresh store per test
export const createCounterStore = () =>
create<CounterState>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// Test
const store = createCounterStore()
store.getState().increment()
expect(store.getState().count).toBe(1)
| Pattern | Purpose |
|---|
| Factory function | Fresh store per test |
beforeEach reset | setState(initial, true) |
getState() | Read state after action |
| Extracted selector | Pure function, testable in isolation |
Common Pitfalls
| Pitfall | Fix |
|---|
| Component re-renders on every change | Use atomic selectors |
| v5 crash on object selector | Wrap with useShallow |
| Nested fields disappear | Spread the nested object |
| UI does not update | Create new references with set |
| Persisted modal reopens | Exclude transient fields with partialize |
| Hydration mismatch (SSR) | skipHydration: true |
| Actions disappear after reset | Don’t use set(initialState, true) without actions |
| Tests share state | Factory function or beforeEach reset |
Quick How-To Guides & Fast FixesNeed a quick fix or step-by-step solution? Browse our fast answers on HowTo.KandZ.me:
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!