wandres.dev
INMUTABILIDAD · structural sharing

Immer: mutar un draft para obtener una copia inmutable

Escribe código que parece mutable sobre un borrador y recibe una versión inmutable con structural sharing incluido. Cómo lo logra con Proxies y copy-on-write, qué son los patches y el auto-freeze, y por qué es el motor oculto de Redux Toolkit.

⏱ 17 min

La actualización inmutable a mano es correcta pero incómoda: anidar spreads para cambiar una hoja profunda es ruidoso y propenso a errores. Immer resuelve la ergonomía sin renunciar a nada. Escribes código que parece mutación directa sobre un borrador, y Immer te devuelve una copia inmutable con structural sharing incluido, dejando el original intacto. No es magia: son Proxies y copy-on-write. Entender cómo funciona por dentro es entender cómo se puede tener lo mejor de los dos mundos.

🎯 Al terminar esta lección sabrás
  • Usar produce para escribir actualizaciones legibles y seguras.
  • Entender cómo los Proxies interceptan lecturas y escrituras.
  • Ver el copy-on-write que genera structural sharing automático.
  • Conocer patches, auto-freeze y su uso en Redux Toolkit y React.

produce: escribe mutable, obtén inmutable

La función central de Immer es produce. Recibe un estado base y una receta —una función que recibe un borrador, el draft—. Dentro de la receta “mutas” el borrador con la sintaxis más natural del mundo. Immer observa lo que tocaste y construye una versión nueva e inmutable; el base no se altera.

import { produce } from "immer";

const base = { user: { name: "Ada", roles: ["admin"] }, count: 0 };

const next = produce(base, (draft) => {
  draft.user.name = "Grace";     // parece mutacion directa...
  draft.user.roles.push("dev");  // ...incluso sobre arrays anidados
  draft.count += 1;
});

base.user.name;    // "Ada"   -> el original INTACTO
next.user.name;    // "Grace"
next === base;     // false

Compara ese produce con su equivalente a mano —tres niveles de spread— y verás por qué Immer se volvió omnipresente. Y no solo es legible: hace structural sharing por ti. Lo que no tocaste conserva su referencia.

const raiz = { a: { x: 1 }, b: { y: 2 } };
const salida = produce(raiz, (d) => { d.a.x = 99; });
salida.b === raiz.b;   // true  -> 'b' intacto, misma referencia
salida.a === raiz.a;   // false -> 'a' cambio, referencia nueva

Cómo funciona: Proxies y copy-on-write

Cuando llamas a produce, Immer no te entrega el estado real: te entrega un Proxy que lo envuelve. Un Proxy de JavaScript intercepta cada lectura y cada escritura. Mientras solo lees, el Proxy te reenvía los valores del base sin copiar nada. En cuanto escribes en un nodo por primera vez, Immer hace una copia superficial de ese nodo —copy-on-write—, marca el camino como “sucio” y redirige las escrituras a la copia. Las ramas que nunca tocaste jamás se copian: se reutilizan.

flowchart TD
B[estado base congelado] --> P[Proxy borrador]
P -->|lees| B
P -->|primera escritura| C[copia superficial del nodo]
C --> N[nuevo arbol resultante]
B -.ramas no tocadas.-> N

Al terminar la receta, Immer recorre solo los nodos marcados como sucios y ensambla el árbol final: nodos nuevos en el camino modificado, referencias compartidas en todo lo demás. Es exactamente el structural sharing del nivel anterior, pero derivado de forma automática a partir de qué propiedades tocó tu código. Por eso Immer no puede ser más lento que hacerlo a mano bien: realiza las mismas copias mínimas, solo que decidiéndolas por observación en vez de por tu escritura explícita.

Dentro de la receta, el draft es ese Proxy, así que si lo logueas verás objetos difíciles de inspeccionar. Immer expone dos ventanas para depurar sin romper el proceso: current te da una copia inmutable del estado del borrador en ese instante, y original te devuelve el valor base intacto de antes de tus cambios.

import { produce, current, original } from "immer";

produce(base, (draft) => {
  draft.count += 1;
  console.log(current(draft));   // snapshot inmutable del draft AHORA
  console.log(original(draft));  // el estado base ANTES de tus cambios
});
⚠️
No mezcles mutar y devolver

Una receta tiene dos modos legítimos: mutar el draft y no devolver nada, o devolver un valor nuevo y no tocar el draft. Mezclar ambos —mutar y además hacer return— es un error que Immer detecta y castiga. Si necesitas reemplazar el estado entero por algo calculado, devuélvelo; si necesitas ajustar campos, muta el draft. Para producir undefined como nuevo estado, devuelve el símbolo nothing.

ℹ️
El coste del Proxy

El único sobrecoste de Immer frente a la actualización manual es el del Proxy: interceptar lecturas y escrituras cuesta un poco. Para la inmensa mayoría de actualizaciones de UI es imperceptible. En bucles calientes que producen millones de versiones diminutas puede notarse; ahí conviene medir y, si hace falta, agrupar los cambios en un solo produce.

Patches, freezing y el ecosistema

Immer sabe exactamente qué cambió, así que puede emitir un registro de los cambios: los patches. Con produceWithPatches obtienes el resultado, los parches que llevan del base al resultado y los parches inversos que deshacen el cambio. Es undo/redo y sincronización cliente-servidor casi gratis.

import { produceWithPatches, enablePatches } from "immer";
enablePatches();

const base = { count: 0 };
const [next, parches, inversos] = produceWithPatches(base, (d) => {
  d.count += 1;
});
// parches:  [ { op: "replace", path: [ "count" ], value: 1 } ]
// inversos: [ { op: "replace", path: [ "count" ], value: 0 } ]

En desarrollo, Immer congela el resultado con Object.freeze de forma recursiva (auto-freeze), de modo que cualquier mutación accidental fuera de una receta lanza un error y la cazas al instante. En producción ese congelado se puede desactivar por rendimiento. Cuando necesitas mutar un borrador a lo largo de operaciones asíncronas, produce no sirve porque es síncrono: usa createDraft y finishDraft para llevar el ciclo de vida en tus manos.

import { createDraft, finishDraft } from "immer";

const draft = createDraft(base);   // borrador de vida larga
draft.count = await calcular();    // puedes mutarlo entre awaits
const listo = finishDraft(draft);  // lo cierras y obtienes el inmutable

El motivo por el que Immer es hoy infraestructura y no una librería más es Redux Toolkit: createSlice envuelve tus reducers en produce, y por eso puedes “mutar” el state en un reducer sin romper la regla de pureza de Redux. Lo que escribes parece mutación; lo que ejecuta Immer es actualización inmutable con sharing.

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

type Todo = { id: string; done: boolean };

const slice = createSlice({
  name: "todos",
  initialState: [] as Todo[],
  reducers: {
    add(state, action: PayloadAction<Todo>) {
      state.push(action.payload);   // "mutacion" segura: Immer la envuelve
    },
    toggle(state, action: PayloadAction<string>) {
      const t = state.find((x) => x.id === action.payload);
      if (t) t.done = !t.done;
    },
  },
});

Redux Toolkit lleva la idea más lejos con createEntityAdapter, que mantiene colecciones normalizadas por id —justo la forma de estado que abarata el structural sharing— y genera reducers y selectores memoizados sobre ellas, todos apoyados en Immer por debajo. En React fuera de Redux, el hook useImmer te da el mismo patrón sobre useState:

import { useImmer } from "use-immer";

const [todos, setTodos] = useImmer<Todo[]>([]);
setTodos((draft) => { draft.push({ id: "1", done: false }); });  // "mutas" el draft

Y para estructuras con Map y Set está el plugin enableMapSet. El resultado transversal: la ergonomía de la mutación, con las garantías de la inmutabilidad.

Immer reconcilia dos mundos que parecían enemigos

Durante años el debate fue binario: o la comodidad de mutar, o las garantías de no hacerlo. Immer disuelve el dilema demostrando que la mutación puede ser una interfaz y la inmutabilidad la implementación. Tú piensas y escribes en términos mutables —lo más natural para el cerebro humano, que razona sobre objetos que cambian— mientras que el resultado observable es un valor inmutable con identidad referencial fiable y structural sharing óptimo. La pieza que lo hace posible es el Proxy: una capa de intercepción que convierte tus asignaciones en un registro de intenciones, y ese registro en el conjunto mínimo de copias necesarias. Entender esto cambia cómo lees el ecosistema: Redux Toolkit no “permite mutar”, traduce; el auto-freeze no es paranoia, es la red que garantiza que la interfaz mutable no se filtre a la realidad; los patches no son un extra, son la consecuencia natural de que Immer ya sabía exactamente qué cambiaste. Cuando una herramienta te deja escribir del modo más simple y aun así te entrega las propiedades más difíciles, es que ha encontrado la abstracción correcta. Immer la encontró.

⚔️ Abre la caja negra de Immer
  1. Reescribe una actualización con tres niveles de anidamiento primero a mano con spreads y luego con produce; compara la legibilidad y verifica con === que ambos comparten las mismas ramas.
  2. Usa produceWithPatches para implementar un undo aplicando los parches inversos con applyPatches.
  3. Activa el auto-freeze, intenta mutar el resultado fuera de una receta y observa el error; luego usa current dentro de una receta para loguear el borrador de forma legible.
  4. Abre un createSlice de Redux Toolkit y razona, línea por línea, qué hace Immer por debajo de cada reducer “mutable”.