Middleware de Immer
Receta
Usa el middleware immer para escribir actualizaciones de state con estilo mutable que producen state inmutable por debajo. Esto elimina el spread manual de objetos para actualizaciones anidadas.
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;
}),
}))
);Ejemplo funcional
// 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>
);
}Análisis profundo
Cómo funciona
- El middleware
immerenvuelve la funciónset. Cuando llamas aset((state) => { ... }), immer crea un draft proxy del state actual. - Mutas el draft directamente (
push,splice,delete,assign). Immer rastrea todos los cambios y produce un nuevo objeto de state inmutable. - Solo las partes modificadas del árbol de state obtienen nuevas referencias. Las ramas sin cambios conservan sus referencias originales, preservando la memoización de React.
- Immer usa ES Proxy internamente. El draft se comporta como un objeto mutable pero nunca modifica el state real.
- Después de que el callback de
setretorna, immer finaliza el draft en un nuevo state congelado y Zustand provoca un re-renderizado.
Variaciones
Sin immer (spread manual):
// Esto es lo que escribirías sin immer
formatCell: (cellId, format) =>
set((state) => ({
cells: {
...state.cells,
[cellId]: {
...state.cells[cellId],
format: {
...state.cells[cellId].format,
...format,
},
},
},
})),Immer con persist:
create<Store>()(
persist(
immer((set) => ({ ... })),
{ name: "store" }
)
);Devolver un nuevo state en lugar de mutar:
// También puedes devolver un nuevo valor desde set con immer
set((state) => {
// Devolver reemplaza el state (igual que sin immer)
return { count: state.count + 1 };
});Notas de TypeScript
- El middleware immer preserva todos los tipos del store. No se necesita tipado adicional.
- Dentro de
set, el parámetrostateestá tipado comoDraft<State>, lo que hace que todas las propiedades sean mutables. - Usa
create<State>()(immer(...))con doble invocación para una inferencia de tipos correcta.
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
// Los tipos funcionan igual con o sin immer
export const useStore = create<MyState>()(
immer((set) => ({
nested: { deep: { value: 0 } },
update: () =>
set((state) => {
state.nested.deep.value = 42; // Completamente tipado
}),
}))
);Trampas comunes
immerdebe ser el middleware más interno (el más cercano al creador del store). Colocarlo fuera depersistodevtoolsno funcionará correctamente.- No devuelvas un valor Y mutar el draft en el mismo callback de
set. O mutas o devuelves, no ambas cosas. Immer lanzará un error si haces las dos. - Immer añade ~6KB gzip a tu bundle. Si tu state es plano y simple, el spread manual puede ser preferible.
- Immer no puede hacer proxy de ciertos tipos: Maps, Sets y clases no están soportados en Immer v9 sin
enableMapSet(). Llámalo una vez en el punto de entrada de tu app si lo necesitas. - El
statedentro desetes un draft Proxy. No lo pases a funciones async ni lo guardes para usarlo después. Se vuelve inválido después de quesetretorna. Object.keys()yfor...infuncionan en drafts, pero algunos casos límite conArray.isArray()o comprobacionesinstanceofpueden comportarse de forma inesperada en draft proxies.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Middleware de Immer | Actualizaciones anidadas limpias, sintaxis familiar | Tamaño del bundle, sobrecarga de Proxy |
| Spread manual | Sin dependencias, control total | Verboso para state profundamente anidado |
| structuredClone + mutate | No requiere librería | Clona todo el state, sin structural sharing |
| Diseño de state plano | Sin anidamiento profundo que gestionar | Puede requerir normalización |
Preguntas frecuentes
¿Qué problema resuelve el middleware immer?
- Elimina el spread manual de objetos para actualizaciones de state anidadas.
- Escribes código con estilo mutable (
state.count += 1) que produce state inmutable por debajo. - Las actualizaciones profundamente anidadas que requieren 3-4 niveles de spread se convierten en una sola línea.
¿Necesitas instalar immer por separado?
- Sí. Ejecuta
npm install immer. El middleware immer de Zustand importa desdezustand/middleware/immerpero depende del paqueteimmer.
¿Cómo produce immer state inmutable a partir de código mutable?
- Immer crea un draft Proxy del state actual.
- Mutas el draft directamente (
push,splice,delete,assign). - Después de que el callback de
setretorna, immer finaliza el draft en un nuevo objeto de state congelado. - Solo las ramas modificadas obtienen nuevas referencias; las ramas sin cambios conservan las referencias originales.
¿Dónde debe ir immer en la pila de middleware?
- Siempre el más interno (el más cercano al creador del store).
- Ejemplo:
devtools(persist(immer((set) => ({ ... })))).
Trampa común: ¿Puedes mutar el draft Y devolver un nuevo valor en el mismo callback de set?
- No. Immer lanzará un error si haces ambas cosas.
- O mutas el draft o devuelves un nuevo valor, nunca las dos.
Trampa común: ¿Puedes pasar el draft state a una función async o guardarlo para después?
- No. El draft proxy se vuelve inválido después de que
setretorna. - No lo pases a funciones async, no lo guardes en una variable ni lo uses fuera del callback de
set.
¿Cuánto añade immer al tamaño del bundle?
- Aproximadamente 6KB gzip.
- Si tu state es plano y simple, el spread manual puede ser preferible para evitar la dependencia extra.
¿Immer soporta Maps y Sets?
- No por defecto en Immer v9. Debes llamar a
enableMapSet()una vez en el punto de entrada de tu app. - Las clases tampoco están soportadas sin configuración explícita.
¿Cómo se tipa el parámetro state dentro de set al usar immer en TypeScript?
- El parámetro
stateestá tipado comoDraft<State>, lo que hace que todas las propiedades sean mutables. - No se necesita tipado adicional: el middleware immer preserva todos los tipos del store.
export const useStore = create<MyState>()(
immer((set) => ({
nested: { deep: { value: 0 } },
update: () => set((state) => {
state.nested.deep.value = 42; // Completamente tipado
}),
}))
);¿Necesitas la doble invocación create<State>()() con immer?
- Sí. Usa
create<State>()(immer(...))para una inferencia de TypeScript correcta. - Este es el mismo patrón requerido para todo el middleware de Zustand.
¿Cómo se ve el código equivalente sin immer para una actualización profundamente anidada?
// Sin immer (spread manual)
formatCell: (cellId, format) =>
set((state) => ({
cells: {
...state.cells,
[cellId]: {
...state.cells[cellId],
format: { ...state.cells[cellId].format, ...format },
},
},
})),
// Con immer
formatCell: (cellId, format) =>
set((state) => {
Object.assign(state.cells[cellId].format, format);
}),