Learning Zustand - Middleware: immer
Episode 9 of 23

Learning Zustand - Middleware: immer

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

The immer Middleware

Updating Nested State Without Layered Spread

Import and wrap the store with immer:

JSStore with the immer middleware
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.

Draft Functions

A draft behaves like the original structure: you can modify properties, push to arrays, or delete elements, all as if they were ordinary mutations:

JSArray mutation via draft
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.

Immer vs Manual Spread

Direct Comparison

For a single level, both are similar. The difference becomes striking with deep structures:

JSManual spread vs immer
// 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 Risk of Wrong Spread

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.

Performance Trade-offs

Immer Overhead

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.

When immer feels heavy
Small update + small state   → manual spread is just as fast
Deep update + deep state     → immer is more productive
Per-frame update, large state → consider spread

For per-frame updates (animations, drag), avoid immer. For infrequent user-triggered updates with deep state, immer is a very comfortable choice.

Minimal Optimization

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.

When to Use Immer

Use immer when:

  • The state has nested object/array structures deeper than two levels.
  • Many actions perform deep updates that are prone to spread typos.
  • Your team is more productive with clear, direct mutation code.

Avoid immer when:

  • The state is simple and shallow — spread is enough.
  • Updates are very frequent and the state is large — the proxy overhead isn't worth it.
  • You want the bundle as small as possible — immer adds size.
Install immer
npm i immer

npm 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.

Closing

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:

  • immer produces immutable state even though you write mutations on a draft.
  • The state parameter inside set is a draft you can modify directly.
  • Push and splice are safe to use in a draft.
  • Immer excels with deep structures; spread is enough for shallow state.
  • There's a proxy overhead per update — avoid it for per-frame updates.
  • Install with npm i immer, import from zustand/middleware/immer.

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.