Use immutable spreads for a small nested update; use the immer middleware when copying every level obscures the change. Both approaches keep actions beside state in a store created with create.
Update each nested level with set
set merges only the top-level state. For nested data, return a new object at each level on the path to the changed value and spread each existing object to preserve its other fields.
The example mounts a counter with two buttons. The first uses explicit immutable copies; the second uses the Immer middleware's draft update. Either button increments the nested count shown on screen.
-
Install Zustand and Immer, which the middleware requires as a direct dependency:
bashnpm install zustand immer -
Put this in your React entry file, such as
src/main.tsx. The HTML page needs a<div id="root"></div>mount point.tsximport { createRoot } from 'react-dom/client' import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' type State = { deep: { nested: { obj: { count: number } } } } type Actions = { incrementWithSpreads: () => void incrementWithImmer: () => void } const useCounterStore = create<State & Actions>()( immer((set) => ({ deep: { nested: { obj: { count: 0 } } }, incrementWithSpreads: () => set((state) => ({ deep: { ...state.deep, nested: { ...state.deep.nested, obj: { ...state.deep.nested.obj, count: state.deep.nested.obj.count + 1, }, }, }, })), incrementWithImmer: () => set((state) => { state.deep.nested.obj.count += 1 }), })), ) function NestedCounter() { const count = useCounterStore((state) => state.deep.nested.obj.count) const incrementWithSpreads = useCounterStore( (state) => state.incrementWithSpreads, ) const incrementWithImmer = useCounterStore( (state) => state.incrementWithImmer, ) return ( <main> <p>Nested count: {count}</p> <button onClick={incrementWithSpreads}>Increment with spreads</button> <button onClick={incrementWithImmer}>Increment with Immer</button> </main> ) } const container = document.getElementById('root') if (!container) { throw new Error('Missing #root mount point') } createRoot(container).render(<NestedCounter />)
After either button is clicked, the displayed nested count increases by one. The spread-based action explicitly copies deep, nested, and obj; the Immer action changes the draft at the target path. The component selects the count and each action, so a count change updates the rendered value.
Choose the update style
| Approach | What you write | Use it when |
|---|---|---|
Immutable update with set | Return a new object for every nested level on the changed path, spreading each existing object. | The nested update is short and the copies make the change clear. |
| Immer middleware | Wrap the store initializer with immer, then mutate the draft in a set callback. | Explicit copies make a deeply nested update hard to read. |
The middleware lets the set callback use mutable syntax while Immer produces the immutable update. You do not need to call produce yourself for this pattern.
Pitfall
Do not replace a nested object with only the changed field. Zustand's merge is shallow, so that replacement discards sibling fields in the nested object. Spread the existing nested value before overriding the field, as incrementWithSpreads does.
For class instances used with Immer, see Immutable state updates. For selector reference stability, see Select state efficiently.
Live demo
Try the authors' updating-state demo to see nested updates in a running React app.
Was this page helpful?