Immer Middleware
Recipe
Use the immer middleware to write mutable-style state updates that produce immutable state under the hood. This eliminates manual object spreading for nested updates.
npm install immerimport { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface Todo {
id: string;
text: string;
done: boolean;
subtasks: { id: string; text: string; done: boolean }[];
}
interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
addSubtask: (todoId: string, text: string) => void;
toggleSubtask: (todoId: string, subtaskId: string) => void;
}
export const useTodoStore = create<TodoStore>()(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({
id: crypto.randomUUID(),
text,
done: false,
subtasks: [],
});
}),
toggleTodo: (id) =>
set((state) => {
const todo = state.todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
}),
addSubtask: (todoId, text) =>
set((state) => {
const todo = state.todos.find((t) => t.id === todoId);
if (todo) {
todo.subtasks.push({ id: crypto.randomUUID(), text, done: false });
}
}),
toggleSubtask: (todoId, subtaskId) =>
set((state) => {
const todo = state.todos.find((t) => t.id === todoId);
const subtask = todo?.subtasks.find((s) => s.id === subtaskId);
if (subtask) subtask.done = !subtask.done;
}),
}))
);Working Example
// stores/spreadsheet-store.ts
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { devtools } from "zustand/middleware";
interface Cell {
value: string;
formula: string | null;
format: {
bold: boolean;
italic: boolean;
color: string;
backgroundColor: string;
};
}
interface SpreadsheetStore {
cells: Record<string, Cell>;
selectedCell: string | null;
setCellValue: (cellId: string, value: string) => void;
setCellFormula: (cellId: string, formula: string) => void;
formatCell: (cellId: string, format: Partial<Cell["format"]>) => void;
selectCell: (cellId: string | null) => void;
batchUpdate: (updates: { cellId: string; value: string }[]) => void;
clearCell: (cellId: string) => void;
}
const defaultCell: Cell = {
value: "",
formula: null,
format: { bold: false, italic: false, color: "#000000", backgroundColor: "#ffffff" },
};
export const useSpreadsheetStore = create<SpreadsheetStore>()(
devtools(
immer((set) => ({
cells: {},
selectedCell: null,
setCellValue: (cellId, value) =>
set((state) => {
if (!state.cells[cellId]) {
state.cells[cellId] = { ...defaultCell };
}
state.cells[cellId].value = value;
state.cells[cellId].formula = null;
}),
setCellFormula: (cellId, formula) =>
set((state) => {
if (!state.cells[cellId]) {
state.cells[cellId] = { ...defaultCell };
}
state.cells[cellId].formula = formula;
}),
formatCell: (cellId, format) =>
set((state) => {
if (!state.cells[cellId]) {
state.cells[cellId] = { ...defaultCell };
}
Object.assign(state.cells[cellId].format, format);
}),
selectCell: (cellId) =>
set((state) => {
state.selectedCell = cellId;
}),
batchUpdate: (updates) =>
set((state) => {
for (const { cellId, value } of updates) {
if (!state.cells[cellId]) {
state.cells[cellId] = { ...defaultCell };
}
state.cells[cellId].value = value;
}
}),
clearCell: (cellId) =>
set((state) => {
delete state.cells[cellId];
}),
})),
{ name: "SpreadsheetStore" }
)
);// components/cell.tsx
"use client";
import { useSpreadsheetStore } from "@/stores/spreadsheet-store";
export function Cell({ cellId }: { cellId: string }) {
const cell = useSpreadsheetStore((s) => s.cells[cellId]);
const selectedCell = useSpreadsheetStore((s) => s.selectedCell);
const setCellValue = useSpreadsheetStore((s) => s.setCellValue);
const selectCell = useSpreadsheetStore((s) => s.selectCell);
const isSelected = selectedCell === cellId;
return (
<td
className={isSelected ? "border-blue-500 border-2" : "border"}
onClick={() => selectCell(cellId)}
>
{isSelected ? (
<input
autoFocus
value={cell?.value ?? ""}
onChange={(e) => setCellValue(cellId, e.target.value)}
style={{
fontWeight: cell?.format.bold ? "bold" : "normal",
fontStyle: cell?.format.italic ? "italic" : "normal",
color: cell?.format.color,
}}
/>
) : (
<span>{cell?.value ?? ""}</span>
)}
</td>
);
}Deep Dive
How It Works
- The
immermiddleware wraps thesetfunction. When you callset((state) => { ... }), immer creates a draft proxy of the current state. - You mutate the draft directly (push, splice, delete, assign). Immer tracks all changes and produces a new immutable state object.
- Only changed parts of the state tree get new references. Unchanged branches keep their original references, preserving React's memoization.
- Immer uses ES Proxy internally. The draft looks and behaves like a mutable object but never modifies the actual state.
- After the
setcallback returns, immer finalizes the draft into a new frozen state and Zustand triggers a re-render.
Variations
Without immer (manual spreading):
// This is what you would write without immer
formatCell: (cellId, format) =>
set((state) => ({
cells: {
...state.cells,
[cellId]: {
...state.cells[cellId],
format: {
...state.cells[cellId].format,
...format,
},
},
},
})),Immer with persist:
create<Store>()(
persist(
immer((set) => ({ ... })),
{ name: "store" }
)
);Returning new state instead of mutating:
// You can also return a new value from set with immer
set((state) => {
// Return replaces state (same as without immer)
return { count: state.count + 1 };
});TypeScript Notes
- Immer middleware preserves all store types. No additional typing is needed.
- Inside
set, thestateparameter is typed asDraft<State>, which makes all properties mutable. - Use
create<State>()(immer(...))with double invocation for correct type inference.
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
// Types work identically with or without immer
export const useStore = create<MyState>()(
immer((set) => ({
nested: { deep: { value: 0 } },
update: () =>
set((state) => {
state.nested.deep.value = 42; // Fully typed
}),
}))
);Gotchas
immermust be the innermost middleware (closest to the store creator). Placing it outsidepersistordevtoolswill not work correctly.- Do not return a value AND mutate the draft in the same
setcallback. Either mutate or return, not both. Immer will throw if you do both. - Immer adds ~6KB gzipped to your bundle. If your state is flat and simple, manual spreading may be preferable.
- Immer cannot proxy certain types: Maps, Sets, and classes are not supported in Immer v9 without
enableMapSet(). Call it once at your app's entry point if needed. - The
stateinsidesetis a Proxy draft. Do not pass it to async functions or store it for later use. It becomes invalid aftersetreturns. Object.keys()andfor...inwork on drafts, but some edge cases withArray.isArray()orinstanceofchecks may behave unexpectedly on draft proxies.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Immer middleware | Clean nested updates, familiar syntax | Bundle size, Proxy overhead |
| Manual spreading | No dependencies, full control | Verbose for deeply nested state |
| structuredClone + mutate | No library needed | Clones entire state, no structural sharing |
| Flat state design | No deep nesting to worry about | May require normalization |
FAQs
What problem does the immer middleware solve?
- It eliminates manual object spreading for nested state updates.
- You write mutable-style code (
state.count += 1) that produces immutable state under the hood. - Deeply nested updates that require 3-4 levels of spreading become one-liners.
Do you need to install immer separately?
- Yes. Run
npm install immer. The Zustand immer middleware imports fromzustand/middleware/immerbut depends on theimmerpackage.
How does immer produce immutable state from mutable code?
- Immer creates a Proxy draft of the current state.
- You mutate the draft directly (push, splice, delete, assign).
- After the
setcallback returns, immer finalizes the draft into a new frozen state object. - Only changed branches get new references; unchanged branches keep original references.
Where should immer go in the middleware stack?
- Always innermost (closest to the store creator).
- Example:
devtools(persist(immer((set) => ({ ... })))).
Gotcha: Can you both mutate the draft AND return a new value in the same set callback?
- No. Immer will throw an error if you do both.
- Either mutate the draft or return a new value, never both.
Gotcha: Can you pass the draft state to an async function or store it for later?
- No. The draft proxy becomes invalid after
setreturns. - Do not pass it to async functions, store it in a variable, or use it outside the
setcallback.
How much does immer add to the bundle size?
- Approximately 6KB gzipped.
- If your state is flat and simple, manual spreading may be preferable to avoid the extra dependency.
Does immer support Maps and Sets?
- Not by default in Immer v9. You must call
enableMapSet()once at your app's entry point. - Classes are also not supported without explicit configuration.
How is the state parameter typed inside set when using immer in TypeScript?
- The
stateparameter is typed asDraft<State>, which makes all properties mutable. - No additional typing is needed -- immer middleware preserves all store types.
export const useStore = create<MyState>()(
immer((set) => ({
nested: { deep: { value: 0 } },
update: () => set((state) => {
state.nested.deep.value = 42; // Fully typed
}),
}))
);Do you need the double invocation create<State>()() with immer?
- Yes. Use
create<State>()(immer(...))for correct TypeScript inference. - This is the same pattern required for all Zustand middleware.
What does the equivalent code look like without immer for a deeply nested update?
// Without immer (manual spreading)
formatCell: (cellId, format) =>
set((state) => ({
cells: {
...state.cells,
[cellId]: {
...state.cells[cellId],
format: { ...state.cells[cellId].format, ...format },
},
},
})),
// With immer
formatCell: (cellId, format) =>
set((state) => {
Object.assign(state.cells[cellId].format, format);
}),