Skip to content
D
Documentation

Update nested state with Immer

how-to
2 min readUpdated

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:

bash
npm 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.

tsx
import { 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 rendered profile name, theme control, and two todo items appear.

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?