Zustand 06 ⚛️ Nested State — Updating complex & deeply nested objects
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
| Depth | Pattern | Example |
|---|---|---|
| 1 level | { ...state.obj, key: value } | { ...state.user, name } |
| 2 levels | Spread both levels | { ...state.user, prefs: { ...state.user.prefs, theme } } |
| Object + array | Spread object, copy array | { ...state.user, tags: [...state.user.tags, tag] } |
| Object in array | .map() + spread | todos.map((t) => t.id === id ? { ...t, text } : t) |
| Array in object in array | Map, spread, copy array | todos.map((t) => t.id === id ? { ...t, meta: { ...t.meta, tags: [...t.meta.tags, tag] } } : t) |
| Dynamic key | Computed property | { ...state.social, [network]: handle } |
Structural Sharing
| Branch | Reference After Update |
|---|---|
| Changed branch | New reference |
| Unchanged sibling | Same reference |
| Unchanged descendant | Same reference |
| Unchanged array element | Same reference |
Alternatives
| Approach | Trade-off |
|---|---|
| Manual spreads | No dependency; verbose at depth |
| Immer middleware | Mutating syntax; requires immer package |
| Flattened state | Simple 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Nested fields disappear | Replaced the object without spreading | Spread the existing object first |
| UI does not update | Mutated a nested object or array in place | Create a new reference at every changed level |
| Unrelated components re-render | Spread too shallow, replaced a parent | Spread only the path to the changed value |
| Dynamic key update fails | Used a static key | Use computed property [key] |
| Deep nesting unreadable | Manual spreads at four or five levels | Use 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
| Item | Value |
|---|---|
set merge depth | Shallow (one level) |
| Copy mechanism | Spread ... for objects, .map()/.filter() for arrays |
| Changed level | Requires a new reference |
| Unchanged level | Retains its reference (structural sharing) |
| Object + array | Spread the object, copy the array |
| Object in array | .map() + spread on the match |
| Dynamic key | Computed property [key] |
| Deep nesting | Immer middleware or flattened state |
| Missing spread | Replaces the subtree, loses fields |
| In-place mutation | No re-render, stale UI |
Key takeaways:
setmerges 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
immermiddleware allows mutating syntax insidesetand 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.
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!