| | |

Zustand 06 ⚛️ Nested State — Updating complex & deeply nested objects

⏱️ Estimated read: 14 min | |

Zustand stores are plain JavaScript objects, and set performs a shallow merge. That merge replaces the top-level keys it is given and leaves everything else untouched. For flat state—a count, a flag, a list of strings—this is sufficient. For nested state—a user object with preferences inside it, a list of items each containing a list of tags, a settings tree with multiple levels—the shallow merge does not reach inside. The developer must copy every level that changes, creating new object and array references along the path from the root to the modified value. Missing a level replaces the entire subtree with the partial object, silently losing the fields that were not included.

The previous chapters covered the set function and immutable update patterns for one level. This chapter extends those patterns to arbitrary depth: the spread-at-every-level rule, the array-inside-object case, the update-by-id pattern for lists of objects, the structural sharing that makes unchanged branches retain their references, and the trade-offs between manual spreading, Immer, and selector-driven flattening.

Key point: Every level on the path from the root to the changed value must be copied with a new reference. Levels that do not change retain their existing references, which is what makes the update efficient and what allows selectors watching unchanged branches to avoid re-rendering. The spread operator ... is the mechanism for copying an object level, and .map() or .filter() is the mechanism for copying an array level. A missing spread at any level replaces the subtree instead of updating it.


Why nested updates require explicit copying

The shallow-merge limitation.

set({ user: { name: 'Ada' } }) replaces the entire user object with { name: 'Ada' }. If the previous user had an email field, it is now gone. The merge is one level deep; it does not recurse into user and merge the new name with the existing email. The correct update is set({ user: { ...state.user, name: 'Ada' } }), which copies the existing user fields and overrides the name . This pattern must be repeated at every level of nesting.

The structural-sharing benefit.

When a level is copied with the spread operator, its unchanged children retain their references. If user.preferences.theme is updated, the preferences object is copied, but the notifications object inside it is not—it is carried forward by the spread with the same reference. A selector that watches user.preferences.notifications sees the same reference before and after the update, so the component does not re-render. This is structural sharing: the new state tree shares every unchanged subtree with the old one, and the comparison mechanism in the selectors exploits it .

The array-in-object problem.

An array inside an object requires two copies: one for the object that contains the array, and one for the array itself. set({ user: { ...state.user, tags: [...state.user.tags, newTag] } }) copies the user object and the tags array. Missing the object spread replaces the user; missing the array spread mutates the existing array in place, which bypasses the reference comparison and prevents the component from re-rendering . The two spreads are not interchangeable; both are required.


a. The spread-at-every-level pattern

The manual pattern for nested updates is to spread every level from the root to the changed value, and to use .map() or .filter() for arrays.

import { create } from 'zustand'

interface Preferences {
  theme: 'light' | 'dark'
  notifications: boolean
  language: string
}

interface User {
  name: string
  email: string
  preferences: Preferences
  tags: string[]
}

interface UserState {
  user: User
  updateName: (name: string) => void
  toggleTheme: () => void
  addTag: (tag: string) => void
}

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

  // One level: copy the user object
  updateName: (name) =>
    set((state) => ({
      user: { ...state.user, name },
    })),

  // Two levels: copy the user object and the preferences object
  toggleTheme: () =>
    set((state) => ({
      user: {
        ...state.user,
        preferences: {
          ...state.user.preferences,
          theme: state.user.preferences.theme === 'light' ? 'dark' : 'light',
        },
      },
    })),

  // Object plus array: copy the user object and the tags array
  addTag: (tag) =>
    set((state) => ({
      user: { ...state.user, tags: [...state.user.tags, tag] },
    })),
}))

The toggleTheme action copies two levels: the user object and the preferences object inside it. The email, name, and tags fields are carried forward by the first spread, and the notifications and language fields are carried forward by the second. The addTag action copies the user object and the tags array, leaving the preferences object with its original reference.


b. Updating an object inside an array

A list of objects adds one more level: the array itself, the object at the matching index, and any nested fields inside that object. The .map() method creates the new array, and the spread operator creates the new object for the matching element.

interface Todo {
  id: number
  text: string
  meta: { priority: 'low' | 'high'; tags: string[] }
}

interface TodoState {
  todos: Todo[]
  updateText: (id: number, text: string) => void
  updatePriority: (id: number, priority: 'low' | 'high') => void
  addTag: (id: number, tag: string) => void
}

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

  // Array level only: map creates a new array, spread creates a new todo
  updateText: (id, text) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, text } : todo
      ),
    })),

  // Array, todo, and meta: three levels of copying
  updatePriority: (id, priority) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id
          ? { ...todo, meta: { ...todo.meta, priority } }
          : todo
      ),
    })),

  // Array, todo, meta, and tags array: four levels
  addTag: (id, tag) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id
          ? { ...todo, meta: { ...todo.meta, tags: [...todo.meta.tags, tag] } }
          : todo
      ),
    })),
}))

The updatePriority action copies the array with .map(), copies the matching todo with the spread, and copies the meta object with another spread. The todos that do not match are returned unchanged, preserving their references. A selector that watches a specific todo’s text does not re-render when a different todo’s priority changes, because the unmatched todos retain their references .

The addTag action copies one more level: the tags array inside meta. The pattern is the same at every level—spread the object, copy the array—and the depth of the nesting determines how many spreads are required.


c. Immer and the flattening alternative

The manual spread pattern is correct but becomes difficult to read at four or five levels of nesting. Immer, which Redux Toolkit uses internally, allows mutating syntax inside a function that produces an immutable result. The immer middleware for Zustand wraps set so that the updater function receives a draft and records the mutations .

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface State {
  user: User
  todos: Todo[]
  updatePriority: (id: number, priority: 'low' | 'high') => void
}

export const useStore = create<State>()(
  immer((set) => ({
    user: { /* ... */ },
    todos: [],

    // Immer draft: direct mutation is safe
    updatePriority: (id, priority) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id)
        if (todo) todo.meta.priority = priority
      }),
  }))
)

The Immer middleware requires the immer package to be installed separately. It adds a small runtime cost and a dependency, but it reduces the nested update to the same code that a mutable update would use. The structural sharing is preserved: Immer tracks which parts of the draft were modified and produces a new state tree with only those branches replaced .

An alternative to both patterns is to flatten the state so that nested objects are stored at the top level and referenced by ID. A todos array becomes a todosById record and an allIds array. A user object with preferences becomes separate user, preferences, and notifications slices. Flat state is easier to update because the spread is only one level deep, and selectors are easier to write because the path to each value is short. The trade-off is that the store shape no longer mirrors the data’s natural structure, and the components must reassemble the nested view from the flat pieces .


Complete Example Session

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

interface Address {
  street: string
  city: string
  country: string
}

interface Profile {
  name: string
  email: string
  address: Address
  social: { twitter: string | null; github: string | null }
}

interface ProfileState {
  profile: Profile
  updateCity: (city: string) => void
  updateSocial: (network: 'twitter' | 'github', handle: string | null) => void
  clearAddress: () => void
}

// ============================================
// PART 2: THE STORE WITH NESTED UPDATES
// ============================================
export const useProfileStore = create<ProfileState>()((set) => ({
  profile: {
    name: 'Ada',
    email: 'ada@example.com',
    address: { street: '1 Main St', city: 'London', country: 'UK' },
    social: { twitter: '@ada', github: 'ada' },
  },

// ============================================
// PART 3: TWO-LEVEL UPDATE
// ============================================
  updateCity: (city) =>
    set((state) => ({
      profile: {
        ...state.profile,
        address: { ...state.profile.address, city },
      },
    })),

// ============================================
// PART 4: DYNAMIC KEY UPDATE
// ============================================
  // The network is a variable, so the key must be computed
  updateSocial: (network, handle) =>
    set((state) => ({
      profile: {
        ...state.profile,
        social: { ...state.profile.social, [network]: handle },
      },
    })),

// ============================================
// PART 5: RESETTING A NESTED BRANCH
// ============================================
  // Replace the entire address object with a fresh one
  clearAddress: () =>
    set((state) => ({
      profile: {
        ...state.profile,
        address: { street: '', city: '', country: '' },
      },
    })),
}))

// ============================================
// PART 6: THE SELECTOR THAT BENEFITS FROM STRUCTURAL SHARING
// ============================================
// A component that watches the social object
// does not re-render when the address changes,
// because the social object retains its reference.
function SocialLinks() {
  const social = useProfileStore((state) => state.profile.social)
  return (
    <div>
      <span>{social.twitter}</span>
      <span>{social.github}</span>
    </div>
  )
}

The six parts demonstrate the nested update patterns: the two-level copy for updateCity, the computed-key copy for updateSocial, the branch replacement for clearAddress, and the selector in Part 6 that benefits from structural sharing. The updateSocial action uses a computed property name [network] inside the spread, which is the pattern for updating a field whose key is determined at runtime.


Quick Reference

Update Patterns by Depth

DepthPatternExample
1 level{ ...state.obj, key: value }{ ...state.user, name }
2 levelsSpread both levels{ ...state.user, prefs: { ...state.user.prefs, theme } }
Object + arraySpread object, copy array{ ...state.user, tags: [...state.user.tags, tag] }
Object in array.map() + spreadtodos.map((t) => t.id === id ? { ...t, text } : t)
Array in object in arrayMap, spread, copy arraytodos.map((t) => t.id === id ? { ...t, meta: { ...t.meta, tags: [...t.meta.tags, tag] } } : t)
Dynamic keyComputed property{ ...state.social, [network]: handle }

Structural Sharing

BranchReference After Update
Changed branchNew reference
Unchanged siblingSame reference
Unchanged descendantSame reference
Unchanged array elementSame reference

Alternatives

ApproachTrade-off
Manual spreadsNo dependency; verbose at depth
Immer middlewareMutating syntax; requires immer package
Flattened stateSimple updates; components reassemble the view

Best Practices

✅ Do This:

// Spread every level that changes
set((state) => ({ user: { ...state.user, prefs: { ...state.user.prefs, theme } } })) // ✅

// Use map for arrays of objects
set((state) => ({ todos: state.todos.map((t) => t.id === id ? { ...t, text } : t) })) // ✅

// Use computed keys for dynamic fields
set((state) => ({ social: { ...state.social, [network]: handle } }))  // ✅

// Preserve references for unchanged elements
todos.map((t) => t.id === id ? { ...t, changed } : t)                 // ✅

// Use Immer for deep nesting
immer((set) => ({ update: (id) => set((s) => { s.items.find(...).nested.value = x }) })) // ✅

❌ Don’t Do This:

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

// Mutate the nested object in place
state.user.preferences.theme = 'dark'    // No re-render              # ❌

// Mutate an array inside an object
state.user.tags.push(tag)    // Same reference; no re-render           # ❌

// Return the same array after mutation
const todos = state.todos; todos[0].done = true; set({ todos })        # ❌

// Use Object.assign on the draft without copying
set({ user: Object.assign(state.user, { name }) })    // Mutates       # ❌

Common Pitfalls

PitfallWhy It HappensFix
Nested fields disappearReplaced the object without spreadingSpread the existing object first
UI does not updateMutated a nested object or array in placeCreate a new reference at every changed level
Unrelated components re-renderSpread too shallow, replaced a parentSpread only the path to the changed value
Dynamic key update failsUsed a static keyUse computed property [key]
Deep nesting unreadableManual spreads at four or five levelsUse Immer middleware or flatten the state

Real-World Examples

1. Update a nested string

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

2. Update a nested object field

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

3. Add to an array inside an object

set((state) => ({ user: { ...state.user, tags: [...state.user.tags, tag] } }))

4. Update an object in an array

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

5. Update a nested field of an array element

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

6. Add to an array inside an object inside an array

set((state) => ({
  todos: state.todos.map((t) =>
    t.id === id ? { ...t, meta: { ...t.meta, tags: [...t.meta.tags, tag] } } : t
  ),
}))

7. Dynamic key update

set((state) => ({ social: { ...state.social, [network]: handle } }))

8. Replace a branch entirely

set((state) => ({ profile: { ...state.profile, address: { street: '', city: '', country: '' } } }))

9. Immer middleware

import { immer } from 'zustand/middleware/immer'
create<State>()(immer((set) => ({ update: (id) => set((s) => { s.items.find((i) => i.id === id).done = true }) })))

10. Flattened state

{ todosById: Record<number, Todo>, allIds: number[] }

Visual

Structural Sharing

┌──────────────────────────────────────────────────────────────┐
│  UNCHANGED BRANCHES KEEP THEIR REFERENCES                    │
│                                                              │
│  Before:                                                     │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ profile                                                │  │
│  │ ├── name: "Ada"                                        │  │
│  │ ├── email: "ada@example.com"                           │  │
│  │ ├── address ──────▶ { street, city, country }  (ref A) │  │
│  │ └── social ───────▶ { twitter, github }        (ref B) │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           │ updateCity("Paris")              │
│                           ▼                                  │
│  After:                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ profile                          ← new reference       │  │
│  │ ├── name: "Ada"                                        │  │
│  │ ├── email: "ada@example.com"                           │  │
│  │ ├── address ──────▶ { street, city: "Paris", country } │  │
│  │ │                     ← new reference                  │  │
│  │ └── social ───────▶ { twitter, github }        (ref B) │  │
│  │                       ← SAME reference                 │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Selectors watching social see ref B before and after.       │
│  They do not re-render.                                      │
└──────────────────────────────────────────────────────────────┘

The Depth Map

┌──────────────────────────────────────────────────────────────┐
│  EACH LEVEL THAT CHANGES NEEDS A NEW REFERENCE               │
│                                                              │
│  state                                                       │
│  └── user                    ← spread if anything below changes│
│      ├── name                ← primitive; no spread needed   │
│      ├── preferences         ← spread if theme changes       │
│      │   ├── theme           ← the changed value             │
│      │   └── notifications   ← carried by the spread         │
│      └── tags                ← copy the array if it changes  │
│                                                              │
│  updateTheme:                                                │
│  { user: { ...user, preferences: { ...preferences, theme } } }│
│           │           │                                          │
│           └───────────┴── both levels copied                │
└──────────────────────────────────────────────────────────────┘

Manual Spread vs Immer

┌──────────────────────────────────────────────────────────────┐
│  TWO WAYS TO UPDATE THE SAME NESTED VALUE                    │
│                                                              │
│  Manual spread:                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ set((state) => ({                                      │  │
│  │   todos: state.todos.map((todo) =>                     │  │
│  │     todo.id === id                                     │  │
│  │       ? { ...todo, meta: { ...todo.meta, priority } }  │  │
│  │       : todo                                           │  │
│  │   ),                                                   │  │
│  │ }))                                                    │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Immer middleware:                                           │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ set((state) => {                                       │  │
│  │   const todo = state.todos.find((t) => t.id === id)    │  │
│  │   if (todo) todo.meta.priority = priority              │  │
│  │ })                                                     │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Immer produces the same structural sharing.                 │
│  The trade-off is an extra package and a small runtime cost. │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
set merge depthShallow (one level)
Copy mechanismSpread ... for objects, .map()/.filter() for arrays
Changed levelRequires a new reference
Unchanged levelRetains its reference (structural sharing)
Object + arraySpread the object, copy the array
Object in array.map() + spread on the match
Dynamic keyComputed property [key]
Deep nestingImmer middleware or flattened state
Missing spreadReplaces the subtree, loses fields
In-place mutationNo re-render, stale UI

Key takeaways:

  • set merges only one level deep. A nested update requires copying every level on the path from the root to the changed value. The spread operator copies an object level; .map() or .filter() copies an array level .
  • Every changed level needs a new reference. The selectors compare references with Object.is. A level that is mutated in place retains its reference, and the component watching it does not re-render. A level that is spread gets a new reference, and the component re-renders.
  • Unchanged branches retain their references. This is structural sharing. A selector watching an unchanged sibling sees the same reference and does not re-render. The efficiency of nested updates depends on spreading only the path that changed, not the entire tree.
  • Object-in-array updates require .map() plus spread. The .map() creates the new array, and the spread creates the new object for the matching element. The non-matching elements are returned unchanged, preserving their references.
  • Immer is the escape hatch for deep nesting. The immer middleware allows mutating syntax inside set and produces the same structural sharing that manual spreads produce. The trade-off is an additional package and a small runtime cost .
  • Flattening is the alternative to deep nesting. Storing nested objects as separate top-level slices or as records keyed by ID makes every update a one-level spread. The trade-off is that the components must reassemble the nested view from the flat pieces.

Remember: Nested state in Zustand is plain JavaScript objects, and the set function merges them one level deep. Every level that changes must be copied with a new reference, and every level that does not change retains its old reference. The spread operator is the copy mechanism for objects, and .map() is the copy mechanism for arrays. The depth of the nesting determines the number of spreads. Immer reduces the verbosity at the cost of a dependency, and flattening reduces the depth at the cost of a less natural shape. Choose the approach that matches the nesting depth and the team’s tolerance for manual spreads.


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!