Skip to content
D
Documentation

Immutable state updates

concept
2 min readUpdated

Zustand keeps state immutable: update through the store API, and return new references for the parts that changed. The default update is a shallow merge; nested values and complete-state replacement are explicit choices.

How the update works

The create function gives a React component a bound store hook. Its initializer receives set, which accepts a partial object or an updater function. Zustand compares the updater result with the current state using Object.is; when the result differs, it either shallowly merges the object or replaces the state.

mermaid
flowchart LR
  A["set(updater)"] --> B["next state"]
  B --> C{"Object.is(next, current)?"}
  C -->|yes| D["no notification"]
  C -->|no| E{"replace = true?"}
  E -->|no| F["shallow merge one level"]
  E -->|yes| G["replace complete state"]
  F --> H["notify subscribers"]
  G --> H

The reference check is about the state value returned by the updater. For an object nested inside that state, mutate-in-place can leave the nested reference unchanged, so return a new nested object instead.

Shallow updates

Use a partial object for a flat update. The other top-level properties remain in the store:

tsx
import { create } from 'zustand'

type PersonState = {
  firstName: string
  lastName: string
  updateFirstName: (firstName: string) => void
}

const usePersonStore = create<PersonState>()((set) => ({
  firstName: 'Ada',
  lastName: 'Lovelace',
  updateFirstName: (firstName) => set({ firstName }),
}))

export function PersonEditor() {
  const firstName = usePersonStore((state) => state.firstName)
  const lastName = usePersonStore((state) => state.lastName)
  const updateFirstName = usePersonStore((state) => state.updateFirstName)

  return (
    <main>
      <label>
        First name
        <input
          value={firstName}
          onChange={(event) => updateFirstName(event.currentTarget.value)}
        />
      </label>
      <p>{firstName} {lastName}</p>
    </main>
  )
}

Typing in the input updates firstName while lastName stays in the state. The component selects each value separately, so each selected primitive changes only when that value changes.

Nested updates

The merge stops at the first level. Preserve each object on the path to the field you change:

tsx
import { create } from 'zustand'

type Settings = {
  theme: string
  density: 'comfortable' | 'compact'
}

type SettingsState = {
  settings: Settings
  setTheme: (theme: string) => void
}

const useSettingsStore = create<SettingsState>()((set) => ({
  settings: { theme: 'light', density: 'comfortable' },
  setTheme: (theme) =>
    set((state) => ({
      settings: { ...state.settings, theme },
    })),
}))

export function SettingsPanel() {
  const settings = useSettingsStore((state) => state.settings)
  const setTheme = useSettingsStore((state) => state.setTheme)

  return (
    <section>
      <p>Theme: {settings.theme}</p>
      <p>Density: {settings.density}</p>
      <button type="button" onClick={() => setTheme('dark')}>
        Use dark theme
      </button>
    </section>
  )
}

Clicking the button changes theme and keeps density. Omitting ...state.settings would create a new settings object without density, because the default merge does not recursively merge nested objects. For deeper structures, copy every changed level, or use the Immer update guide.

Replacing the complete state

Pass true as the second argument to set when the returned value is the complete state model. Replacement also removes properties that are not in the new value, including actions in a store that defines actions in its state.

This standalone example uses createStore from the zustand/vanilla package and binds it to React with useStore:

tsx
import { createStore } from 'zustand/vanilla'
import { useStore } from 'zustand'

type NoticeState = {
  message: string
  visible: boolean
}

const noticeStore = createStore<NoticeState>(() => ({
  message: 'Saved',
  visible: true,
}))

export function Notice() {
  const notice = useStore(noticeStore)

  return (
    <section>
      <p>{notice.visible ? notice.message : 'No notice'}</p>
      <button
        type="button"
        onClick={() =>
          noticeStore.setState(
            { message: 'Archived', visible: false },
            true,
          )
        }
      >
        Archive
      </button>
    </section>
  )
}

Before the click, the component shows Saved. After the click, replacement changes the complete NoticeState value and the component shows No notice. Use replacement only when you have every property required by the state type.

Collections and references

Map and Set are mutable objects, so create a new instance when changing one. The new instance gives the selected value a new reference:

ts
import { createStore } from 'zustand/vanilla'

type TagState = {
  tags: Set<string>
}

const tagStore = createStore<TagState>(() => ({ tags: new Set<string>() }))

const addTag = (tag: string) => {
  tagStore.setState((state) => ({
    tags: new Set(state.tags).add(tag),
  }))
}

Do not mutate state.tags and return the same Set. Its reference remains equal, so a selector of tags does not observe a changed reference. Type empty collections explicitly when TypeScript cannot infer their element types.

Next steps

Was this page helpful?

Immutable state updates — zustand · GPT-5.6 Luna