//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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;
}),
}))
);// 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>
);
}immer middleware wraps the set function. When you call set((state) => { ... }), immer creates a draft proxy of the current state.set callback returns, immer finalizes the draft into a new frozen state and Zustand triggers a re-render.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 };
});set, the state parameter is typed as Draft<State>, which makes all properties mutable.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
}),
}))
);immer must be the innermost middleware (closest to the store creator). Placing it outside persist or devtools will not work correctly.set callback. Either mutate or return, not both. Immer will throw if you do both.enableMapSet(). Call it once at your app's entry point if needed.state inside set is a Proxy draft. Do not pass it to async functions or store it for later use. It becomes invalid after set returns.Object.keys() and for...in work on drafts, but some edge cases with Array.isArray() or instanceof checks may behave unexpectedly on draft proxies.
| 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 |
state.count += 1) that produces immutable state under the hood.npm install immer. The Zustand immer middleware imports from zustand/middleware/immer but depends on the immer package.set callback returns, immer finalizes the draft into a new frozen state object.devtools(persist(immer((set) => ({ ... })))).set returns.set callback.enableMapSet() once at your app's entry point.state parameter is typed as Draft<State>, which makes all properties mutable.export const useStore = create<MyState>()(
immer((set) => ({
nested: { deep: { value: 0 } },
update: () => set((state) => {
state.nested.deep.value = 42; // Fully typed
}),
}))
);create<State>()(immer(...)) for correct TypeScript inference.// 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);
}),Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥