| | |

Zustand 04 ⚛️ Updating State — Immutable updates with set function

⏱️ Estimated read: 15 min | |

Reading state is passive. The component subscribes, and the store notifies it when something changes. Updating state is active: a function in the store receives new data and calls set to merge that data into the existing state. The set function is the only legitimate way to modify a Zustand store, and the way it is called determines whether React re-renders correctly, whether nested data is updated safely, and whether subscribers receive the values they expect.

The previous chapters established selectors and their comparison behavior. This chapter focuses on the other side of the subscription: the write path. Every set call must produce a new reference for the values it changes, because Zustand and React compare references with Object.is to decide whether to re-render. Mutating the existing object and calling set with the same reference does not produce a re-render. The store may contain the new value, but the subscribers never see it because the reference comparison says nothing changed.

Key point: set performs a shallow merge by default. It replaces the top-level keys provided in the partial object and leaves all other keys untouched. For nested objects and arrays, the merge does not reach inside—you must copy the nested structure manually, creating new references at every level that changes. set also accepts a function that receives the current state, which is the correct way to update values derived from previous state.


Why immutable updates matter

The reference comparison problem.

Zustand’s store is a plain JavaScript object. When set is called, it creates a new state object by shallow-merging the partial update. Selectors then run against the new state and compare their results with the previous results using Object.is. If a selector returns state.todos, and the todos array is the same reference as before, the comparison succeeds and the component does not re-render. If the array is a new reference, the comparison fails and the component re-renders. Mutating the existing array with .push() and calling set({ todos }) passes the same reference, so the selector sees no change. The state has been modified in place, but the subscription mechanism never learns about it.

The shallow-merge limitation.

set({ count: 5 }) merges count into the existing state. It does not replace the entire state. This is convenient for top-level updates, but it creates a trap for nested data. set({ user: { name: 'Ada' } }) replaces the entire user object, removing any other properties that were present. set({ user: { ...state.user, name: 'Ada' } }) preserves the other properties by copying them. The spread operator is the mechanism that creates the new reference while preserving the unchanged fields. The same applies to arrays: set({ items: [...state.items, newItem] }) creates a new array, while state.items.push(newItem) modifies the existing one.

The stale-state problem.

Calling set with a direct object uses the state as it exists at the moment of the call. If an action performs an asynchronous operation and then calls set with a value computed from the state before the await, the value may be stale. The functional form—set((state) => ({ count: state.count + 1 }))—reads the current state at the moment the update is applied, ensuring that concurrent or sequential updates compose correctly. The Zustand documentation states that the functional form is the only way to access the current state inside a set call .


a. Top-level updates with the functional form

The set function accepts either a partial object or an updater function. The updater function receives the current state and returns the partial object to merge.

import { create } from 'zustand'

interface CounterState {
  count: number
  name: string
  increment: () => void
  reset: () => void
  setName: (name: string) => void
}

export const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  name: 'counter',

  // Functional form: reads current count at update time
  increment: () => set((state) => ({ count: state.count + 1 })),

  // Direct object: sets count to a fixed value
  reset: () => set({ count: 0 }),

  // Updates one key, leaves others untouched
  setName: (name) => set({ name }),
}))

The increment action uses the functional form because the new count depends on the previous count. The reset action uses the direct object because the new value is a constant. The setName action uses the direct object because the new value comes from the argument, not from the state. The choice between the two forms depends on whether the new value is derived from the current state.

The shallow merge is visible in setName: calling set({ name: 'new' }) changes name and leaves count and all actions untouched. The actions are part of the state object, and they are merged in the same way as any other property.


b. Updating nested objects and arrays

Nested data requires explicit copying. The spread operator creates a new object or array at each level that changes, and the unchanged levels retain their references.

interface UserState {
  user: {
    name: string
    email: string
    preferences: {
      theme: 'light' | 'dark'
      notifications: boolean
    }
  }
  updateName: (name: string) => void
  toggleTheme: () => void
}

export const useUserStore = create<UserState>()((set) => ({
  user: {
    name: 'Ada',
    email: 'ada@example.com',
    preferences: { theme: 'light', notifications: true },
  },

  // Update a top-level property of a nested object
  updateName: (name) =>
    set((state) => ({
      user: { ...state.user, name },   // New user object, other fields preserved
    })),

  // Update a deeply nested property
  toggleTheme: () =>
    set((state) => ({
      user: {
        ...state.user,                 // New user object
        preferences: {
          ...state.user.preferences,   // New preferences object
          theme: state.user.preferences.theme === 'light' ? 'dark' : 'light',
        },
      },
    })),
}))

The toggleTheme action copies two levels: the user object and the preferences object inside it. The email and notifications fields are carried forward by the spreads. If either spread were omitted, the corresponding object would be replaced rather than updated, and the missing fields would become undefined.

Arrays follow the same pattern. Adding an element requires a new array. Updating an element requires mapping to a new array with a new object at the changed index. Removing an element requires filtering to a new array.

interface TodoState {
  todos: { id: number; text: string; done: boolean }[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  removeTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>()((set) => ({
  todos: [],

  // Add: new array with the new item appended
  addTodo: (text) =>
    set((state) => ({
      todos: [...state.todos, { id: Date.now(), text, done: false }],
    })),

  // Toggle: new array, new object at the matching index
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
    })),

  // Remove: new array without the matching item
  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}))

The toggleTodo action is the most instructive. It uses .map() to create a new array, and inside the map it uses the spread operator to create a new object for the matching todo. The other todos are returned unchanged, preserving their references. This means a selector that watches a specific todo’s done value re-renders only when that todo is toggled, not when any other todo changes.


c. Replacing state and controlling the merge

The set function accepts a second boolean argument: true replaces the entire state instead of merging. This removes all existing properties, including actions, which is almost never what the developer wants. The replacement form is used only when resetting a store to a completely known shape, and even then, the actions must be re-included in the replacement object.

// Replace the entire state: actions are removed unless re-included
set({ count: 0, name: 'counter' }, true)

The documentation notes that the replace flag is rarely necessary and can be dangerous . The merge behavior is the default because it allows actions to coexist with state updates. A set call that merges preserves the functions in the store; a set call that replaces removes them unless they are explicitly part of the replacement.

For resetting a store while preserving actions, the pattern is to define the initial state as a constant and spread it into the merge.

const initialState = { count: 0, name: 'counter' }

export const useCounterStore = create<CounterState>()((set) => ({
  ...initialState,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set(initialState),   // Merges initial values, keeps actions
}))

The reset action merges the initial state values back into the store, overwriting the current values while leaving the actions intact. This is the correct pattern for a reset: merge the initial values, do not replace the entire object.


Complete Example Session

// ============================================
// PART 1: THE STATE SHAPE
// ============================================
import { create } from 'zustand'

interface Item { id: number; name: string; quantity: number; tags: string[] }
interface InventoryState {
  items: Item[]
  lastUpdated: number | null
  addItem: (name: string) => void
  updateQuantity: (id: number, quantity: number) => void
  addTag: (id: number, tag: string) => void
  removeItem: (id: number) => void
}

// ============================================
// PART 2: THE STORE WITH IMMUTABLE UPDATES
// ============================================
export const useInventoryStore = create<InventoryState>()((set) => ({
  items: [],
  lastUpdated: null,

// ============================================
// PART 3: ADD — NEW ARRAY, NEW ITEM
// ============================================
  // Creates a new array with the new item appended
  // The new item is a fresh object; no existing data is mutated
  addItem: (name) =>
    set((state) => ({
      items: [...state.items, { id: Date.now(), name, quantity: 1, tags: [] }],
      lastUpdated: Date.now(),
    })),

// ============================================
// PART 4: UPDATE NESTED FIELD — NEW ARRAY, NEW OBJECT
// ============================================
  // map creates a new array; spread creates a new object for the matching item
  // Other items retain their references
  updateQuantity: (id, quantity) =>
    set((state) => ({
      items: state.items.map((item) =>
        item.id === id ? { ...item, quantity } : item
      ),
      lastUpdated: Date.now(),
    })),

// ============================================
// PART 5: UPDATE ARRAY INSIDE OBJECT
// ============================================
  // Two levels of copying: the item object and its tags array
  addTag: (id, tag) =>
    set((state) => ({
      items: state.items.map((item) =>
        item.id === id
          ? { ...item, tags: [...item.tags, tag] }   // New tags array
          : item
      ),
      lastUpdated: Date.now(),
    })),

// ============================================
// PART 6: REMOVE — NEW ARRAY WITHOUT MATCH
// ============================================
  // filter creates a new array; the removed item is simply absent
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
      lastUpdated: Date.now(),
    })),
}))

// ============================================
// PART 7: SELECTOR VERIFICATION
// ============================================
// A component that selects a specific item's quantity
// re-renders only when that item's quantity changes
function QuantityDisplay({ id }: { id: number }) {
  const quantity = useInventoryStore(
    (state) => state.items.find((item) => item.id === id)?.quantity ?? 0
  )
  return <span>{quantity}</span>
}

The seven parts demonstrate the full immutability discipline: new arrays for additions and removals, new objects for updated items, and nested copying for arrays inside objects. The addTag action is the most involved because it updates a field inside an object that is itself inside an array. Each level requires its own spread.


Quick Reference

set Forms

FormCodeUse When
Direct objectset({ count: 0 })New value is constant or from arguments
Functionalset((state) => ({ count: state.count + 1 }))New value depends on current state
Replaceset({ count: 0 }, true)Resetting the entire store (rare)

Immutable Update Patterns

OperationPattern
Add to array[...state.items, newItem]
Remove from arraystate.items.filter((i) => i.id !== id)
Update array elementstate.items.map((i) => i.id === id ? { ...i, ...changes } : i)
Update object property{ ...state.obj, key: value }
Update nested property{ ...state.obj, nested: { ...state.obj.nested, key: value } }
Reset to initialset(initialState)

Mutation vs Immutability

Mutable (❌)Immutable (✅)
state.items.push(x)set({ items: [...state.items, x] })
state.items[i].done = trueset({ items: state.items.map(...) })
state.user.name = 'Ada'set({ user: { ...state.user, name: 'Ada' } })
set({ items }) after mutationset({ items: [...state.items] })

Best Practices

✅ Do This:

// Use the functional form for state-dependent updates
set((state) => ({ count: state.count + 1 }))                 // ✅

// Copy nested objects with spread
set((state) => ({ user: { ...state.user, name } }))          // ✅

// Create new arrays for additions
set((state) => ({ items: [...state.items, item] }))          // ✅

// Map to update array elements
set((state) => ({ items: state.items.map(...) }))            // ✅

// Merge initial state to reset while keeping actions
set(initialState)                                            // ✅

// Update multiple top-level keys in one call
set({ count: 0, name: 'reset' })                             // ✅

❌ Don’t Do This:

// Mutate the array in place
state.items.push(item); set({ items: state.items })          // ❌

// Mutate a nested object in place
state.user.name = 'Ada'; set({ user: state.user })           // ❌

// Replace a nested object without spreading
set({ user: { name: 'Ada' } })    // Removes email, preferences // ❌

// Use set with a replace flag to reset
set(initialState, true)    // Removes actions                    // ❌

// Call set with a stale value computed before an await
const count = get().count; await something(); set({ count })  // ❌

// Update state outside of set
store.setState is set; direct mutation bypasses subscriptions // ❌

Common Pitfalls

PitfallWhy It HappensFix
UI does not update after state changeMutated object/array in placeCreate new reference with spread or map
Nested properties become undefinedReplaced nested object without spreadingSpread the nested object
Actions disappear after resetUsed replace flag trueMerge initial state instead
Concurrent updates lose dataDirect object with stale stateUse functional form
Selector re-renders every itemUpdated array without preserving referencesMap and spread only the changed item
State updates after unmountAsync action calls set after component unmountsCheck mount status or use abort

Real-World Examples

1. Increment with functional form

increment: () => set((state) => ({ count: state.count + 1 }))

2. Reset with constant

reset: () => set({ count: 0 })

3. Add to array

addTodo: (text) => set((state) => ({ todos: [...state.todos, { text }] }))

4. Update array element

updateTodo: (id, text) =>
  set((state) => ({ todos: state.todos.map((t) => t.id === id ? { ...t, text } : t) }))

5. Remove from array

removeTodo: (id) =>
  set((state) => ({ todos: state.todos.filter((t) => t.id !== id) }))

6. Update nested object

setName: (name) => set((state) => ({ user: { ...state.user, name } }))

7. Update deeply nested value

toggleTheme: () =>
  set((state) => ({
    user: { ...state.user, prefs: { ...state.user.prefs, theme: 'dark' } },
  }))

8. Update array inside object

addTag: (id, tag) =>
  set((state) => ({
    items: state.items.map((i) => i.id === id ? { ...i, tags: [...i.tags, tag] } : i),
  }))

9. Reset while keeping actions

const initialState = { count: 0 }
reset: () => set(initialState)

10. Update multiple keys

set({ count: 0, name: 'new', isActive: false })

Visual

The Shallow Merge

┌──────────────────────────────────────────────────────────────┐
│  set({ count: 1 }) MERGES INTO EXISTING STATE                │
│                                                              │
│  Before:                                                     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ { count: 0, name: 'Ada', increment: [Function] }       │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           ▼                                  │
│  set({ count: 1 })                                           │
│                           │                                  │
│                           ▼                                  │
│  After:                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ { count: 1, name: 'Ada', increment: [Function] }       │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Only count changed. name and increment are preserved.       │
│  The merge is shallow: nested objects are NOT deep-merged.   │
└──────────────────────────────────────────────────────────────┘

Immutable Array Update

┌──────────────────────────────────────────────────────────────┐
│  UPDATING ONE ITEM IN AN ARRAY                               │
│                                                              │
│  Before:                                                     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ todos: [                                               │  │
│  │   { id: 1, text: 'A', done: false },  ← reference X    │  │
│  │   { id: 2, text: 'B', done: false },  ← reference Y    │  │
│  │   { id: 3, text: 'C', done: false },  ← reference Z    │  │
│  │ ]                                                      │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           ▼                                  │
│  set((state) => ({ todos: state.todos.map(                   │
│    (t) => t.id === 2 ? { ...t, done: true } : t              │
│  ) }))                                                       │
│                           │                                  │
│                           ▼                                  │
│  After:                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ todos: [                                               │  │
│  │   { id: 1, text: 'A', done: false },  ← same ref X    │  │
│  │   { id: 2, text: 'B', done: true  },  ← NEW ref W     │  │
│  │   { id: 3, text: 'C', done: false },  ← same ref Z    │  │
│  │ ]                                                      │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  New array reference. Only item 2 is a new object.           │
│  Selectors watching item 1 or 3 do not re-render.            │
└──────────────────────────────────────────────────────────────┘

Nested Update Levels

┌──────────────────────────────────────────────────────────────┐
│  EACH CHANGED LEVEL NEEDS ITS OWN SPREAD                     │
│                                                              │
│  State shape:                                                │
│  user                                                        │
│  ├─ name                                                     │
│  ├─ email                                                    │
│  └─ preferences        ← level 2 object                     │
│      ├─ theme          ← level 3 value                       │
│      └─ notifications                                        │
│                                                              │
│  To change theme:                                            │
│  set((state) => ({                                           │
│    user: {                          ← copy level 1           │
│      ...state.user,                                          │
│      preferences: {                 ← copy level 2           │
│        ...state.user.preferences,                            │
│        theme: 'dark',               ← change level 3 value   │
│      },                                                      │
│    },                                                        │
│  }))                                                         │
│                                                              │
│  Missing a spread → that level is replaced, fields lost.     │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Primary update functionset(partial) or set(updater)
Merge behaviorShallow; top-level keys only
Functional formRequired when new value depends on current state
Replace flagset(partial, true) removes unspecified keys
Array add[...state.items, newItem]
Array removestate.items.filter(...)
Array updatestate.items.map(...) with spread on the match
Nested object updateSpread each level that changes
Reset patternset(initialState) to keep actions
Mutation consequenceNo re-render; subscribers see stale reference

Key takeaways:

  • set merges; it does not replace. Only the top-level keys in the partial object are changed. All other state, including actions, is preserved . The merge is shallow, so nested objects are replaced unless they are explicitly spread.
  • The functional form is mandatory for state-dependent updates. set((state) => ({ count: state.count + 1 })) reads the current state at update time. A direct object with a value computed earlier may be stale.
  • Every changed level needs a new reference. Arrays require a new array; objects require a new object. The spread operator creates the new reference while copying the unchanged fields. Mutating in place bypasses the reference comparison and prevents re-renders.
  • Update array elements with .map() and spread. The map creates a new array, and the spread creates a new object for the matching element. Other elements retain their references, so selectors watching them do not re-render.
  • The replace flag is almost never correct. set(partial, true) removes all unspecified keys, including actions. Use the merge form and reset by merging the initial state values .
  • Nested arrays inside objects require two spreads. The object is copied at its level, and the array is copied at its level. Missing either spread causes the nested data to be replaced or shared unsafely.
  • Reset by merging the initial state. set(initialState) restores the initial values and leaves the actions in place. The replace flag removes the actions along with the state.

Remember: Zustand’s update path is built on reference equality. Every set call must produce new references for the values it changes, because the selectors compare references to decide whether to re-render. The functional form reads the current state. The spread operator creates the new references. The shallow merge preserves what was not changed. Mutating the state object directly is the one mistake that breaks the entire subscription model: the value changes in memory, but the reference does not, and the component never learns that anything happened.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!