Zustand 04 ⚛️ Updating State — Immutable updates with set function
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
| Form | Code | Use When |
|---|---|---|
| Direct object | set({ count: 0 }) | New value is constant or from arguments |
| Functional | set((state) => ({ count: state.count + 1 })) | New value depends on current state |
| Replace | set({ count: 0 }, true) | Resetting the entire store (rare) |
Immutable Update Patterns
| Operation | Pattern |
|---|---|
| Add to array | [...state.items, newItem] |
| Remove from array | state.items.filter((i) => i.id !== id) |
| Update array element | state.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 initial | set(initialState) |
Mutation vs Immutability
| Mutable (❌) | Immutable (✅) |
|---|---|
state.items.push(x) | set({ items: [...state.items, x] }) |
state.items[i].done = true | set({ items: state.items.map(...) }) |
state.user.name = 'Ada' | set({ user: { ...state.user, name: 'Ada' } }) |
set({ items }) after mutation | set({ 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI does not update after state change | Mutated object/array in place | Create new reference with spread or map |
| Nested properties become undefined | Replaced nested object without spreading | Spread the nested object |
| Actions disappear after reset | Used replace flag true | Merge initial state instead |
| Concurrent updates lose data | Direct object with stale state | Use functional form |
| Selector re-renders every item | Updated array without preserving references | Map and spread only the changed item |
| State updates after unmount | Async action calls set after component unmounts | Check 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
| Item | Value |
|---|---|
| Primary update function | set(partial) or set(updater) |
| Merge behavior | Shallow; top-level keys only |
| Functional form | Required when new value depends on current state |
| Replace flag | set(partial, true) removes unspecified keys |
| Array add | [...state.items, newItem] |
| Array remove | state.items.filter(...) |
| Array update | state.items.map(...) with spread on the match |
| Nested object update | Spread each level that changes |
| Reset pattern | set(initialState) to keep actions |
| Mutation consequence | No re-render; subscribers see stale reference |
Key takeaways:
setmerges; 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.
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!