This episode covers the immer middleware for updating nested state in a mutable-but-immutable way through draft functions, comparing it with manual spread, when to use immer versus spread, and the performance trade-offs of both.

Real application state is never simple — nested objects, arrays inside objects, and interconnected structures. In episode 4 we learned to update nested state with layered spread that is error-prone. Episode 9 introduces immer, a middleware that lets you write direct mutations on a draft while still producing immutable state.
We'll cover how drafts work, correct writing patterns, when immer beats manual spread, and when you shouldn't use it.
Import and wrap the store with immer:
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface ProfileState {
profile: {
name: string
address: { city: string; country: string }
}
setCity: (city: string) => void
}
export const useProfile = create<ProfileState>()(
immer((set) => ({
profile: { name: 'Arman', address: { city: 'Bandung', country: 'ID' } },
setCity: (city) =>
set((state) => {
state.profile.address.city = city
}),
})),
)Inside set((state) => ...), the state parameter is a draft. You mutate it directly — state.profile.address.city = city — and immer copies only the changed parts automatically. The final result stays immutable.
A draft behaves like the original structure: you can modify properties, push to arrays, or delete elements, all as if they were ordinary mutations:
addTag: (tag) =>
set((state) => {
state.tags.push(tag)
}),
removeTag: (tag) =>
set((state) => {
const idx = state.tags.indexOf(tag)
if (idx !== -1) state.tags.splice(idx, 1)
}),state.tags.push(tag) and state.tags.splice(idx, 1) are direct mutations that are safe — immer records them as structural changes and produces new state. The code is far shorter than layered spread.
For a single level, both are similar. The difference becomes striking with deep structures:
// Manual spread, four levels
set((s) => ({
settings: {
...s.settings,
theme: {
...s.settings.theme,
accent: 'teal',
},
},
}))
// Immer, one mutation
set((s) => {
s.settings.theme.accent = 'teal'
})set((s) => { s.settings.theme.accent = 'teal' }) replaces four layers of spread with a single draft mutation. The deeper the structure, the bigger immer's readability advantage.
The most common manual spread mistake: forgetting to spread one level, so the old object is still referenced and the change isn't detected. Immer eliminates this class of errors because copying is handled automatically.
Immer isn't free. Every draft needs a proxy and a copy-on-write process, so there's a small overhead per update — very noticeable when the state is huge and updates are high-frequency, for example a text editor or a realtime simulation.
Small update + small state → manual spread is just as fast
Deep update + deep state → immer is more productive
Per-frame update, large state → consider spreadFor per-frame updates (animations, drag), avoid immer. For infrequent user-triggered updates with deep state, immer is a very comfortable choice.
Immer allows updates that change nothing — for example setting the same value — and the result still returns the same reference, so it doesn't trigger a re-render. This is desired behavior.
Use immer when:
Avoid immer when:
npm i immernpm i immer installs the dependency the middleware needs. Zustand exposes immer from zustand/middleware/immer — the immer package version is used automatically from your project's dependencies.
Episode 9 demonstrates the power of immer: nested updates written in a mutable-but-immutable way through drafts, code far shorter than layered spread, and choosing when immer is worth it based on state complexity and update frequency.
Key takeaways:
In the next episode we will discuss the devtools and redux middleware — Redux DevTools integration with time-travel and action tracing, store name configuration, and the redux middleware for Redux-style reducer patterns inside Zustand.