Skip to content
D
Documentation

Update nested state

how-to
2 min readUpdated

Use immutable copies when an action changes a nested object, Map, or Set. For deeply nested objects, use the shipped Immer middleware to write the update with draft mutation syntax instead. The examples below render a counter or a collection summary in React, so you can see each update without losing sibling values.

1. Copy each level of a nested object

Create a store with create and put the update action beside its state. Use a functional set update to read the current value. Copy each object along the path to the changed field, keeping its other fields with object spread. You do not need to spread the top-level state: set merges the returned object at that level.

In your React TypeScript project, use this as src/main.tsx. The page starts at Forest: 0 bears. Click Add bear to increment the count; the title and unit stay visible.

tsx
import { createRoot } from 'react-dom/client'
import { create } from 'zustand'

type CounterStore = {
  deep: {
    title: string
    nested: {
      unit: string
      obj: { count: number; label: string }
    }
  }
  increment: () => void
}

const useCounterStore = create<CounterStore>()((set) => ({
  deep: {
    title: 'Forest',
    nested: { unit: 'bears', obj: { count: 0, label: 'Add bear' } },
  },
  increment: () =>
    set((state) => ({
      deep: {
        ...state.deep,
        nested: {
          ...state.deep.nested,
          obj: {
            ...state.deep.nested.obj,
            count: state.deep.nested.obj.count + 1,
          },
        },
      },
    })),
}))

function App() {
  const deep = useCounterStore((state) => state.deep)
  const increment = useCounterStore((state) => state.increment)

  return (
    <main>
      <p>{deep.title}: {deep.nested.obj.count} {deep.nested.unit}</p>
      <button onClick={increment}>{deep.nested.obj.label}</button>
    </main>
  )
}

createRoot(document.getElementById('root')!).render(<App />)
The counter starts at Forest: 0 bears, with an Add bear button below it.

The update creates new deep, nested, and obj objects. Each spread preserves sibling fields at that level; the top-level merge preserves the increment action. Copy the changed path rather than rebuilding unrelated parts of the store.

2. Use an Immer draft instead of nested spreads

Wrap the state creator in immer from zustand/middleware/immer. The middleware passes function updates through Immer's produce, so you can change a draft inside set while keeping the update immutable. It takes the state creator, not a configuration object.

Install Immer as a direct dependency alongside Zustand:

bash
npm install zustand immer

Replace src/main.tsx with this alternative. It renders the same counter and preserves the same sibling values, but the action changes only the draft's count. Keep the extra parentheses in create<CounterStore>()(...) when using middleware with an explicit state type.

tsx
import { createRoot } from 'react-dom/client'
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

type CounterStore = {
  deep: {
    title: string
    nested: {
      unit: string
      obj: { count: number; label: string }
    }
  }
  increment: () => void
}

const useCounterStore = create<CounterStore>()(
  immer((set) => ({
    deep: {
      title: 'Forest',
      nested: { unit: 'bears', obj: { count: 0, label: 'Add bear' } },
    },
    increment: () =>
      set((state) => {
        state.deep.nested.obj.count += 1
      }),
  })),
)

function App() {
  const deep = useCounterStore((state) => state.deep)
  const increment = useCounterStore((state) => state.increment)

  return (
    <main>
      <p>{deep.title}: {deep.nested.obj.count} {deep.nested.unit}</p>
      <button onClick={increment}>{deep.nested.obj.label}</button>
    </main>
  )
}

createRoot(document.getElementById('root')!).render(<App />)
The Immer counter shows the same Forest title, zero count, and Add bear button.

Pitfall: class instances need Immer support

If your state contains class instances, mark them with [immerable] = true as described in Immer's class documentation, and follow Immer's rules. Without that marker, Immer mutates the instance without proxying it, which also changes the current state. If the current and next state are equal, Zustand skips subscriptions.

3. Copy mutable collections before changing them

For collections, create a new Map or Set from the current collection, then modify that copy. The following standalone alternative uses immutable copies without Immer. It starts with one bear and one selected animal. Click Add bear to increase the stored bear count, or Select fox to add another selected animal while keeping the bear selected.

tsx
import { createRoot } from 'react-dom/client'
import { create } from 'zustand'

type CollectionStore = {
  counts: Map<string, number>
  selected: Set<string>
  addBear: () => void
  selectFox: () => void
}

const useCollectionStore = create<CollectionStore>()((set) => ({
  counts: new Map<string, number>([['bear', 1]]),
  selected: new Set<string>(['bear']),
  addBear: () =>
    set((state) => ({
      counts: new Map(state.counts).set(
        'bear',
        (state.counts.get('bear') ?? 0) + 1,
      ),
    })),
  selectFox: () =>
    set((state) => ({
      selected: new Set(state.selected).add('fox'),
    })),
}))

function App() {
  const counts = useCollectionStore((state) => state.counts)
  const selected = useCollectionStore((state) => state.selected)
  const addBear = useCollectionStore((state) => state.addBear)
  const selectFox = useCollectionStore((state) => state.selectFox)

  return (
    <main>
      <p>Bears: {counts.get('bear') ?? 0}</p>
      <p>Selected: {Array.from(selected).join(', ')}</p>
      <button onClick={addBear}>Add bear</button>
      <button onClick={selectFox}>Select fox</button>
    </main>
  )
}

createRoot(document.getElementById('root')!).render(<App />)
The collection summary starts with one bear and bear selected, alongside Add bear and Select fox buttons.

The changed collection gets a new reference, and existing entries survive the copy. To delete an entry, copy the collection into a local variable, call delete on the copy, and return it from the updater. Give collections explicit type arguments, as above, when initializing them empty too.

For the reference-equality rules behind these updates, see State and actions. For selecting derived collection values, see Selectors and subscriptions.

Live demos

Was this page helpful?