Zustand 08 ⚛️ Middleware Immer — Simplifying complex updates with Immer
The previous chapter on nested state established the manual spread pattern: every level on the path to the changed value must be copied with a new reference, and every array must be copied with .map() or .filter(). The pattern is correct, but at three or four levels of nesting it becomes difficult to read and easy to get wrong. A missing spread at any level replaces the subtree; a missing .map() mutates the array in place and prevents the component from re-rendering. The Immer middleware replaces the spreads with ordinary mutating syntax, and produces the same immutable result behind the scenes.
Immer is a library that wraps a value in a draft proxy. Mutations on the draft are recorded, and when the updater function returns, Immer produces a new immutable value by applying the recorded changes. Zustand’s immer middleware integrates this into set, so that the updater function receives the draft instead of the plain state . The previous chapters covered manual nested updates and the persist middleware; this chapter covers the alternative that trades a small runtime dependency for significantly simpler update code.
Key point: The immer middleware changes what the set function passes to the updater. Without it, the updater receives the plain state and must return a partial object. With it, the updater receives a draft and may either mutate the draft or return a new state. Mutating the draft is the intended usage; returning undefined after mutating is correct, and returning a new value after mutating is an error. Immer preserves structural sharing, so unchanged branches retain their references and selectors continue to work as before.
Why Immer is a middleware and not a default
The draft-identity problem.
Immer’s draft is a proxy, not the actual state object. The state argument inside set is the draft, and the get() function returns the actual state, not the draft. Reading the draft is safe, but comparing the draft’s identity to a stored reference is not. Code that relies on reference equality of the state object inside the updater will behave differently with the middleware . The middleware is opt-in precisely because it changes the contract of the updater function from “return a new partial state” to “mutate a draft or return a new state.”
The performance-cost problem.
Immer adds a small runtime cost. Every set call creates a draft proxy, records the mutations, and produces a new state tree. For shallow updates, the cost is negligible, but for updates that touch a large object without changing anything, Immer still walks the draft to determine that nothing changed. The middleware is worth its cost when the state is deeply nested or the update logic is complex; for flat state with a single primitive field, the manual spread is cheaper and simpler.
The interop problem.
Immer’s middleware is compatible with the other middleware in the chain. The immer middleware can be combined with persist, devtools, and subscribeWithSelector, and the order matters. The persist middleware wraps the state creator, and the immer middleware wraps the set function. When both are used, the immer middleware must be inside the persist middleware, so that the persisted state is the result of Immer’s immutable production rather than the draft . The composition order is covered in the quick reference.
a. Installing and enabling the middleware
The immer middleware is part of Zustand, but the immer library itself is a separate dependency. The middleware will not work without it.
# Install the Immer library
npm install immer
The middleware is imported from zustand/middleware/immer, and it wraps the state creator. The TypeScript syntax uses the curried create<T>()() form, and the immer middleware is applied to the state creator.
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface Todo {
id: number
text: string
meta: { priority: 'low' | 'high'; tags: string[] }
}
interface TodoState {
todos: Todo[]
updatePriority: (id: number, priority: 'low' | 'high') => void
addTag: (id: number, tag: string) => void
}
export const useTodoStore = create<TodoState>()(
immer((set) => ({
todos: [],
// The updater receives a draft; mutate it directly
updatePriority: (id, priority) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.meta.priority = priority
}),
addTag: (id, tag) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.meta.tags.push(tag)
}),
}))
)
The updatePriority action finds the todo in the draft and assigns the priority directly. The addTag action pushes to the draft’s tags array. Neither action creates a spread or a .map(). Immer records the mutations and produces a new state tree with new references at every level that changed . The find call returns the draft’s todo, and mutating it is safe because the mutation is recorded on the proxy.
b. How Immer’s draft works inside set
The middleware passes the draft to the updater, and the updater’s return value is interpreted differently from the plain set. Returning undefined after mutating the draft is the intended usage. Returning a new value is an error if the draft was also mutated.
immer((set) => ({
count: 0,
items: [] as string[],
// Mutate the draft; return nothing
increment: () =>
set((state) => {
state.count += 1
}),
// Mutate an array
addItem: (item: string) =>
set((state) => {
state.items.push(item)
}),
// ❌ INCORRECT: mutating and returning
broken: () =>
set((state) => {
state.count += 1
return { count: state.count } // Immer error: cannot both mutate and return
}),
// ✅ CORRECT: return a new value without mutating
replace: (value: number) =>
set(() => ({ count: value })),
}))
The increment action returns nothing, and the mutation is the update. The broken action mutates the draft and returns a new object, which Immer rejects with an error because it cannot reconcile the two. The replace action does not touch the draft and returns a new partial state, which is the same behavior as the plain set without the middleware . The two styles can be mixed in the same store, but each individual updater must use one or the other.
The draft is a proxy over the current state. Reading from it returns the current values, and writing to it records the change. The get() function returns the actual state, not the draft, so reading through get() inside an action is the same as reading the state outside the updater.
c. Combining Immer with other middleware
The middleware order is determined by the nesting. The innermost middleware is the one closest to the state creator, and the outermost is the one that wraps everything. The immer middleware must be inside persist and devtools, so that the state those middleware see is the immutable result, not the draft.
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
export const useStore = create<State>()(
devtools( // Outermost: logs actions
persist( // Middle: persists the result
immer((set) => ({ // Innermost: provides the draft
// ...
})),
{ name: 'app-storage' }
)
)
)
The devtools middleware wraps persist, which wraps immer. The order is read from the outside in: devtools sees the state after persist has written it, and persist sees the state after immer has produced the immutable result. Reversing the order—putting immer outside persist—would cause persist to receive the draft, and the serialization would fail because the draft is a proxy, not a plain object . The Zustand documentation and the middleware type definitions confirm that immer should be the innermost wrapper when combined with persist.
Complete Example Session
// ============================================
// PART 1: THE STATE SHAPE
// ============================================
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { persist } from 'zustand/middleware'
interface Task {
id: number
title: string
done: boolean
subtasks: { id: number; title: string; done: boolean }[]
}
interface TaskState {
tasks: Task[]
toggleTask: (taskId: number) => void
toggleSubtask: (taskId: number, subtaskId: number) => void
addSubtask: (taskId: number, title: string) => void
renameTask: (taskId: number, title: string) => void
}
// ============================================
// PART 2: THE STORE WITH IMMER
// ============================================
export const useTaskStore = create<TaskState>()(
persist(
immer((set) => ({
tasks: [],
// ============================================
// PART 3: NESTED OBJECT UPDATE
// ============================================
toggleTask: (taskId) =>
set((state) => {
const task = state.tasks.find((t) => t.id === taskId)
if (task) task.done = !task.done
}),
// ============================================
// PART 4: NESTED ARRAY UPDATE
// ============================================
toggleSubtask: (taskId, subtaskId) =>
set((state) => {
const task = state.tasks.find((t) => t.id === taskId)
const subtask = task?.subtasks.find((s) => s.id === subtaskId)
if (subtask) subtask.done = !subtask.done
}),
// ============================================
// PART 5: PUSH TO A NESTED ARRAY
// ============================================
addSubtask: (taskId, title) =>
set((state) => {
const task = state.tasks.find((t) => t.id === taskId)
if (task) task.subtasks.push({ id: Date.now(), title, done: false })
}),
// ============================================
// PART 6: DIRECT FIELD UPDATE
// ============================================
renameTask: (taskId, title) =>
set((state) => {
const task = state.tasks.find((t) => t.id === taskId)
if (task) task.title = title
}),
})),
{
name: 'tasks-storage',
partialize: (state) => ({ tasks: state.tasks }),
}
)
)
The six parts demonstrate the same operations that would require multiple spreads and .map() calls without Immer: a nested object toggle, a nested array element toggle, a push to a nested array, and a direct field update. Each action is a few lines of mutating code, and Immer produces the immutable state tree behind the scenes. The persist middleware wraps the immer middleware, and the partialize option stores only the tasks array, excluding the actions.
Quick Reference
Immer Middleware Setup
| Step | Code |
|---|---|
| Install library | npm install immer |
| Import middleware | import { immer } from 'zustand/middleware/immer' |
| Wrap state creator | create<T>()(immer((set) => ({ ... }))) |
| Mutate draft | state.items.push(item) |
| Return nothing | set((state) => { state.count += 1 }) |
Mutate vs Return
| Action | Result |
|---|---|
Mutate draft, return undefined | ✅ Correct |
| Return new partial, no mutation | ✅ Correct |
| Mutate draft, return new value | ❌ Immer error |
| Return the same draft | ❌ No change detected |
Middleware Order
| Order | Correct | Wrong |
|---|---|---|
| Outer | devtools | immer |
| Middle | persist | persist |
| Inner | immer | devtools |
Best Practices
✅ Do This:
// Install immer as a dependency
npm install immer # ✅
// Use the immer middleware for nested state
create<T>()(immer((set) => ({ ... }))) # ✅
// Mutate the draft directly
set((state) => { state.todos.find((t) => t.id === id).done = true }) # ✅
// Return undefined after mutation
set((state) => { state.count += 1 }) # ✅
// Place immer inside persist
persist(immer((set) => ({ ... })), { name: 'storage' }) # ✅
// Use find to locate the draft element before mutating
const todo = state.todos.find((t) => t.id === id)
if (todo) todo.done = true # ✅
❌ Don’t Do This:
// Forget to install immer
import { immer } from 'zustand/middleware/immer' // Fails at runtime # ❌
// Mutate and return a new value
set((state) => { state.count += 1; return { count: state.count } }) # ❌
// Place immer outside persist
immer(persist((set) => ({ ... }))) // persist receives the draft # ❌
// Use the draft outside the updater
const draft = get(); draft.count += 1 // Not a draft; mutates state # ❌
// Compare the draft to a stored reference
state === previousState // The draft is a proxy, not the state # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
immer middleware not found | The immer package is not installed | npm install immer |
| Immer error on update | Mutated the draft and returned a value | Return undefined after mutation |
persist fails to serialize | immer placed outside persist | Place immer inside persist |
| Change not detected | Returned the same draft | Mutate the draft, return nothing |
| Component does not re-render | Used get() instead of the draft | Mutate the state argument |
| Nested field undefined | The draft path is wrong | Verify the find result before mutating |
Real-World Examples
1. Increment with Immer
set((state) => { state.count += 1 })
2. Toggle a nested boolean
set((state) => { state.user.preferences.notifications = !state.user.preferences.notifications })
3. Push to a nested array
set((state) => { state.todos.find((t) => t.id === id).tags.push(tag) })
4. Toggle an array element
set((state) => { const t = state.todos.find((t) => t.id === id); if (t) t.done = !t.done })
5. Update a nested object field
set((state) => { state.user.address.city = 'Paris' })
6. Remove from an array
set((state) => { state.todos = state.todos.filter((t) => t.id !== id) })
7. Add to a record
set((state) => { state.usersById[id] = newUser })
8. Delete from a record
set((state) => { delete state.usersById[id] })
9. Combine with persist
persist(immer((set) => ({ ... })), { name: 'storage' })
10. Combine with devtools
devtools(persist(immer((set) => ({ ... })), { name: 'storage' }))
Visual
Manual Spread vs Immer
┌──────────────────────────────────────────────────────────────┐
│ THE SAME UPDATE, TWO WAYS │
│ │
│ Manual spread: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ set((state) => ({ │ │
│ │ todos: state.todos.map((todo) => │ │
│ │ todo.id === id │ │
│ │ ? { ...todo, meta: { ...todo.meta, priority } } │ │
│ │ : todo │ │
│ │ ), │ │
│ │ })) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Immer: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ set((state) => { │ │
│ │ const todo = state.todos.find((t) => t.id === id) │ │
│ │ if (todo) todo.meta.priority = priority │ │
│ │ }) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Both produce the same immutable result. │
│ Immer is shorter; the spread is dependency-free. │
└──────────────────────────────────────────────────────────────┘
How the Draft Works
┌──────────────────────────────────────────────────────────────┐
│ IMMER RECORDS MUTATIONS AND PRODUCES A NEW TREE │
│ │
│ Current state (immutable): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { todos: [ { id: 1, meta: { priority: 'low' } } ] } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Draft created (proxy): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ draft.todos[0].meta.priority = 'high' │ │
│ │ → mutation recorded at the meta level │ │
│ └─────────────────────────────────────────��──────────────┘ │
│ │ │
│ ▼ │
│ Immer produces new state: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { todos: [ { id: 1, meta: { priority: 'high' } } ] } │ │
│ │ ↑ new array ↑ new todo ↑ new meta │ │
│ │ todos[0].id retains its reference │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Only the changed path gets new references. │
└──────────────────────────────────────────────────────────────┘
Middleware Order
┌──────────────────────────────────────────────────────────────┐
│ IMMER MUST BE THE INNERMOST MIDDLEWARE │
│ │
│ ✅ Correct: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ devtools(persist(immer((set) => ({ ... })))) │ │
│ │ │ │
│ │ immer produces the immutable state │ │
│ │ persist serializes the result │ │
│ │ devtools logs the result │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ❌ Wrong: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ immer(persist((set) => ({ ... }))) │ │
│ │ │ │
│ │ persist receives the draft │ │
│ │ JSON.stringify(draft) fails or produces garbage │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Middleware import | immer from zustand/middleware/immer |
| Library dependency | immer (separate install) |
| Draft argument | The state passed to the updater |
| Mutation syntax | state.count += 1, state.items.push(x) |
| Return value | undefined after mutation |
| Structural sharing | Preserved by Immer |
| Middleware order | immer innermost, inside persist and devtools |
| Mixed styles | Mutate or return, not both |
Key takeaways:
- The
immermiddleware changes the updater contract. Without it, the updater receives the plain state and must return a partial object. With it, the updater receives a draft and may mutate it directly. Returningundefinedafter mutation is the intended usage . - Immer preserves structural sharing. The draft records mutations, and Immer produces a new state tree with new references only at the levels that changed. Unchanged branches retain their references, and selectors continue to avoid unnecessary re-renders.
- Mutate or return, not both. A single updater must either mutate the draft and return nothing, or return a new partial state without touching the draft. Doing both produces an Immer error .
- The middleware order matters.
immermust be insidepersistanddevtools, so that those middleware see the immutable result rather than the draft proxy. Placingimmeroutside causes serialization and logging to fail . - The
immerlibrary is a separate dependency. The middleware is part of Zustand, but it importsimmerinternally. The package must be installed separately, or the store fails at runtime . - Immer is optional. For flat state and single-field updates, the manual spread is simpler and has no runtime cost. Immer is worth its cost when the state is deeply nested or the update logic involves multiple levels of copying.
Remember: The immer middleware is a convenience that turns mutating syntax into immutable updates. The draft is a proxy, the mutations are recorded, and the new state tree is produced when the updater returns. The middleware is applied inside the state creator, and its position in the middleware chain determines what the other middleware see. Use it for nested state where the manual spreads become unreadable; skip it for flat state where the spreads are trivial. The result is the same immutable state, produced by less code.
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!