Use create with immer when an action changes several levels of an object or a collection. You write the update with draft-mutation syntax; the middleware produces the immutable state that Zustand passes to its subscribers.
When to use it
Use Immer when spreading every level of a nested value makes an action hard to read. Zustand's normal update is a shallow merge, so replacing a nested object without copying its existing properties can discard sibling fields. For a single flat field, a regular set update remains enough.
Install both packages in your application:
bashnpm install zustand immer
Update nested objects and a collection
Define the state and actions together. The immer wrapper changes the set callback so its state argument is an Immer draft.
tsximport { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
type Profile = {
name: string
preferences: {
theme: 'light' | 'dark'
notifications: boolean
}
}
type Todo = {
id: string
title: string
done: boolean
}
type Store = {
profile: Profile
todos: Todo[]
setTheme: (theme: Profile['preferences']['theme']) => void
toggleTodo: (id: string) => void
}
export const useStore = create<Store>()(
immer((set) => ({
profile: {
name: 'Ada Lovelace',
preferences: {
theme: 'light',
notifications: true,
},
},
todos: [
{ id: 'learn-zustand', title: 'Learn Zustand', done: false },
{ id: 'write-example', title: 'Write an example', done: false },
],
setTheme: (theme) =>
set((state) => {
state.profile.preferences.theme = theme
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((item) => item.id === id)
if (todo) {
todo.done = !todo.done
}
}),
})),
)
export function App() {
const name = useStore((state) => state.profile.name)
const theme = useStore((state) => state.profile.preferences.theme)
const todos = useStore((state) => state.todos)
const setTheme = useStore((state) => state.setTheme)
const toggleTodo = useStore((state) => state.toggleTodo)
return (
<main>
<h1>{name}</h1>
<p>Theme: {theme}</p>
<button type="button" onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<button type="button" onClick={() => toggleTodo(todo.id)}>
{todo.done ? 'Done' : 'Open'}: {todo.title}
</button>
</li>
))}
</ul>
</main>
)
}
The mounted component displays the profile name, the current theme, and both todos. Clicking Toggle theme changes only profile.preferences.theme; the other profile fields remain present. Clicking a todo changes that item's done value and updates its label from Open to Done (or back). The selectors subscribe to the values they read, so the component receives the produced state through the store hook.
Keep collection references observable
Do not mutate a Map or Set in place in a regular Zustand update. An in-place mutation keeps the same collection reference, so Zustand may treat the selected value as unchanged. Create a new Map or Set for each update instead. The array update above uses Immer draft syntax, which produces the immutable collection state for the changed item.
Watch the Immer edge cases
Immer must proxy the value you mutate. If you mutate a class object without marking it [immerable] = true, Immer can change the current object without producing a new proxied state. Zustand can then see equal current and next state and skip subscriptions. Mark such class objects as Immerable and follow Immer's rules.
See it running
Open the nested-state demo to see the rendered result in a browser. For the middleware's standalone examples, see the basic and advanced demos.
For selector stability and shallow comparison, continue with Selectors and rendering. For a vanilla store, see Create a vanilla store.
Was this page helpful?