Zustand with Immer: Choose Mutable-Style Updates for Nested State
Decide when to use Zustand's Immer middleware versus manual spread updates, with a TypeScript example and immutability checks.
19 Dec 2025, 23:35 UTC

Decision and constraints
You need to update a value inside a nested object in a Zustand store. The obvious approach is nested spread syntax:
set((state) => ({
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: 'dark',
},
},
}))
This works, but it is verbose and easy to get wrong. A single missed spread mutates the existing state object directly. Because Zustand notifies React components by comparing state references, a silent mutation can leave the UI stale even though the store object changed.
Use this guide if you are using Zustand 4.x or 5.x with TypeScript and React. The decision is whether to write immutable updates by hand, use Zustand's official Immer middleware, or rely on shallow copy helpers.
Supported options compared
Immer lets you write mutable-style code inside a draft that Immer converts into an immutable update. A draft is a temporary proxy object: you assign to it normally, and Immer produces the next immutable state.
| Approach | Best for | Immutability guarantee | Boilerplate | Runtime cost |
|---|---|---|---|---|
| Manual spread or object copy | Flat state or occasional nested edits | Developer discipline only | High for deep nesting | Lowest |
| Immer middleware | Frequent updates to deeply nested state | Enforced by the draft proxy | Low | Small; roughly 5-10% depending on state size and update frequency |
| Shallow copy helpers | Top-level fields only | Surface-level changes only | Medium | Low |
Trade-offs by store shape
Manual immutable updates
No library overhead and no new concepts. The downside is boilerplate that grows with nesting depth, and the compiler will not stop you from mutating state.user directly.
Immer middleware
You can write state.user.preferences.theme = 'dark' inside the updater. Immer finalizes the draft into a new immutable object, so React sees a fresh reference. The cost is a small runtime proxy step and another dependency. If you mix Immer actions with manual spread actions in the same store, the inconsistency can make bugs harder to trace.
Shallow copy helpers
Fast for top-level properties, but they do not protect nested objects. A nested mutation still breaks reference equality for the parent.
Implementation: create a store with Immer
Run this in your project root. No special permissions are required.
npm install zustand immer
Expected check: node_modules/zustand and node_modules/immer exist, and package.json lists both dependencies. Risk: version mismatch between Zustand and the Immer middleware package; use versions from the same major release line.
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface Preference {
theme: 'light' | 'dark'
notifications: boolean
}
interface User {
id: string
preferences: Preference
}
interface Store {
user: User | null
setTheme: (theme: Preference['theme']) => void
}
export const useStore = create()(
immer((set) => ({
user: null,
setTheme: (theme) =>
set((state) => {
// state is an Immer draft. Assignments here are safe.
if (state.user) {
state.user.preferences.theme = theme
}
}),
}))
)
Inside the Immer updater, state is a draft, not the real store state. Mutating the draft is intentional; Immer replaces the store with an immutable copy after the function returns.
TypeScript draft types
Immer makes draft properties mutable automatically, but TypeScript may still complain about assignments in older Zustand or Immer combinations. If you see type errors, annotate the updater parameter or cast the draft, then verify the store still compiles after upgrading either package.
Verify immutability before and after an update
Use this check in a unit test or browser console. It does not modify production data.
const stateBefore = useStore.getState()
const themeBefore = stateBefore.user?.preferences.theme
useStore.getState().setTheme('dark')
const stateAfter = useStore.getState()
// The previous snapshot must not reflect the new theme
console.assert(themeBefore !== 'dark', 'previous state snapshot was mutated')
// Zustand should notify React through a new object reference
console.assert(stateBefore !== stateAfter, 'state reference did not change')
If the first assertion fails, something mutated the old state object. If the second fails, React may not re-render because the reference is unchanged.
Limitations and when to avoid Immer
- Performance: Immer adds a small runtime overhead. Measure your own store if updates run on every animation frame or the state object is very large.
- Consistency: Do not mix Immer actions and manual spread actions in the same store without a team convention.
- Persist middleware: Immer finalizes to plain objects before serialization, so zustand/middleware/persist can store and hydrate the result normally. Verify hydration after any change to the store shape.
- Async code: Do not hold a draft reference across an await. Finalize the draft before any asynchronous boundary.
If your store is flat—settings toggles, counters, strings—manual spread updates are simpler and faster. Adopt Immer when nested edits are frequent enough that spread boilerplate obscures the logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.