wandres.dev
UNDO, REDO Y TIME TRAVEL · historia del estado

Parches: el registro compacto del cambio y su inverso automático

El patrón comando es eficiente pero obliga a escribir cada inversa a mano, y esa duplicación es su talón de Aquiles. Los parches de Immer eliminan el trabajo manual apoyándose en una observación decisiva: la librería ya tenía que saber exactamente qué propiedades tocaste para construir la copia inmutable, así que emitir esa lista de cambios y su lista simétrica no le cuesta nada adicional. Esta lección diseca la anatomía de un parche —operación, ruta y valor—, lo compara con el estándar JSON Patch del que desciende, y muestra cómo produceWithPatches convierte un estilo de escritura por instantáneas en un historial con el coste de un historial por comandos, obteniendo lo mejor de las dos lecciones anteriores sin pagar el precio de ninguna. Después traza con precisión los tres límites que hay que respetar para no corromper el estado: la disciplina LIFO estricta en la aplicación de inversos, la ausencia de semántica en un parche que solo describe posiciones y no intenciones, y la incapacidad estructural de un historial lineal de parches para resolver ediciones concurrentes.

⏱ 18 min

Las dos lecciones anteriores dejaron un dilema aparentemente cerrado. Las instantáneas se escriben solas pero pesan; los comandos pesan poco pero hay que escribirlos dos veces, una hacia delante y otra hacia atrás, y esa segunda vez es donde se cuelan los errores que corrompen el historial sin avisar. La salida a ese dilema no vino de un algoritmo nuevo sino de mirar dónde estaba ya la información que hacía falta. Cuando escribes una actualización con Immer, la librería no adivina qué copiar: intercepta con un Proxy cada asignación que haces sobre el borrador y anota el camino exacto de cada propiedad tocada, porque sin esa anotación no podría construir la copia inmutable ni compartir las ramas intactas. Es decir, en el instante en que termina tu receta, Immer conoce con precisión quirúrgica la lista completa de cambios y también, para cada uno, el valor que había antes. Emitir esas dos listas es regalar algo que ya estaba calculado. De ahí salen los parches, y de ahí sale la conclusión que da sentido a esta lección: puedes escribir tu código en el estilo cómodo de las instantáneas y obtener un historial con el peso y la precisión de los comandos, con las inversas derivadas por observación en vez de redactadas a mano.

🎯 Al terminar esta lección sabrás
  • Leer la anatomía de un parche: op, path y value, y su parentesco con el estándar JSON Patch.
  • Producir cambios, parches e inversos en una sola llamada y aplicarlos para deshacer y rehacer.
  • Entender por qué el inverso es gratuito y en qué se diferencia de una inversa escrita a mano.
  • Delimitar los tres límites: orden LIFO, ausencia de semántica y ausencia de resolución de concurrencia.

Anatomía de un parche

Un parche es un objeto pequeño que describe un cambio atómico mediante tres campos: la operación realizada, la ruta al punto del árbol afectado y el valor implicado. Las operaciones son tres —reemplazar, añadir y quitar— y la ruta es un array de claves e índices que localiza el nodo sin ambigüedad. Ese diseño desciende directamente de JSON Patch, el formato normalizado en la RFC 6902, con una diferencia deliberada: JSON Patch codifica la ruta como una cadena con barras, mientras que Immer la deja como array de segmentos, lo que evita el escapado y hace el recorrido más barato en tiempo de ejecución.

import { produceWithPatches, applyPatches, enablePatches } from "immer"
enablePatches()   // el plugin no viene activo por defecto

const base = { doc: { titulo: "borrador", tags: ["a"] }, vistas: 0 }

const [siguiente, parches, inversos] = produceWithPatches(base, (d) => {
  d.doc.titulo = "final"
  d.doc.tags.push("b")
})

// parches:
//   [ { op: "replace", path: [ "doc", "titulo" ], value: "final" },
//     { op: "add",     path: [ "doc", "tags", 1 ], value: "b" } ]
// inversos:
//   [ { op: "replace", path: [ "doc", "titulo" ], value: "borrador" },
//     { op: "remove",  path: [ "doc", "tags", 1 ] } ]

Merece la pena leer despacio la lista de inversos, porque contiene toda la tesis de la lección. El inverso de reemplazar es otro reemplazar con el valor antiguo, que Immer conocía porque lo leyó del estado base antes de sobrescribirlo. El inverso de añadir es quitar en la misma ruta, y no necesita transportar valor alguno. Ninguna de las dos inversas ha sido escrita por nadie: se derivan mecánicamente de la operación y del contexto que el Proxy ya había capturado. Esto es exactamente el patrón comando de la lección anterior, con la diferencia de que la parte propensa a error se genera sola.

flowchart TD
R[receta que muta el draft] --> PX[Proxy intercepta cada escritura]
PX --> N[estado siguiente inmutable]
PX --> P[lista de parches]
PX --> I[lista de inversos]
P -->|applyPatches| RE[rehacer]
I -->|applyPatches en orden inverso| DE[deshacer]
style PX fill:#cba6f7,color:#11111b
style I fill:#a6e3a1,color:#11111b

Conviene además notar qué no aparece en esas listas. No hay ningún parche para las ramas del árbol que la receta no tocó, aunque el estado resultante sea un objeto nuevo. Esa ausencia es la misma economía que produce el structural sharing, expresada ahora en forma de registro: el parche describe la frontera exacta entre lo que cambió y lo que se reutilizó, y por eso su tamaño es proporcional al cambio y no al documento. Un parche sobre un árbol de cien mil nodos donde se modificó un campo pesa lo mismo que sobre un árbol de diez.

📝
Compatibilidad con JSON Patch

Si necesitas interoperar con un servicio que espera JSON Patch estándar, la conversión es mecánica: basta unir los segmentos de path con barras y escapar los caracteres reservados según la especificación de JSON Pointer. Merece la pena hacerlo cuando el consumidor es ajeno a tu código; dentro de tu aplicación, el formato nativo de Immer es más rápido de recorrer y más fácil de inspeccionar.

El historial de parches

Antes de construirlo conviene fijar una regla de higiene: todo cambio del documento debe pasar por la misma función productora. Un historial de parches solo es correcto si es completo, y basta una única mutación que se escape por otro camino para que las rutas de los inversos posteriores dejen de corresponderse con la realidad. Esa disciplina no cuesta nada si se adopta desde el principio y es prácticamente imposible de recuperar cuando ya hay veinte lugares que tocan el estado.

Con parches e inversos disponibles, el historial deja de guardar estados y pasa a guardar pares de listas. Cada entrada pesa lo que pesa el cambio, no lo que pesa el documento, y el registro resultante es serializable, lo que significa que sobrevive a un refresco de página, cabe en un log de auditoría y puede enviarse a un servidor.

type Entrada = { parches: unknown[]; inversos: unknown[] }
type Pila = { hechas: Entrada[]; deshechas: Entrada[] }

function aplicarCambio<S>(s: S, pila: Pila, receta: (d: S) => void): [S, Pila] {
  const [siguiente, parches, inversos] = produceWithPatches(s, receta)
  return [siguiente, { hechas: [...pila.hechas, { parches, inversos }], deshechas: [] }]
}

function deshacer<S>(s: S, pila: Pila): [S, Pila] {
  const e = pila.hechas.at(-1)
  if (!e) return [s, pila]
  // los inversos se aplican en orden inverso al que fueron emitidos
  const previo = applyPatches(s, [...e.inversos].reverse() as never)
  return [previo, { hechas: pila.hechas.slice(0, -1), deshechas: [...pila.deshechas, e] }]
}

La línea que invierte el array antes de aplicarlo no es cosmética y es el error más frecuente al adoptar parches. Los inversos describen cómo desandar cada cambio suponiendo que los cambios posteriores ya han sido desandados; aplicarlos en el orden en que fueron emitidos funciona por casualidad cuando las rutas son independientes y falla en cuanto dos parches tocan el mismo array, porque los índices de un parche presuponen la longitud que el array tenía en ese instante. La disciplina es la misma pila de última entrada, primera salida que exigía la composición de comandos, y aquí es igual de innegociable.

💡
Los parches tambien son el formato de sincronizacion

El mismo objeto que sirve para deshacer sirve para propagar. Enviar al servidor la lista de parches de una edición, en lugar del documento completo, reduce el tráfico a una fracción y permite que otros clientes apliquen el cambio con applyPatches sin conocer nada del código que lo originó. Es la vía más corta entre un historial local y una sincronización incremental, y explica por qué tantos editores colaborativos empiezan aquí antes de plantearse estructuras más sofisticadas.

Los tres límites que hay que respetar

Los parches resuelven la ergonomía y el coste, pero no son una solución universal, y confundir su alcance produce fallos difíciles de diagnosticar. Tres límites acotan su uso, y los tres son estructurales, no defectos de implementación.

🔒

Contexto exacto

Un inverso solo es válido aplicado sobre el estado que resultó de su parche. Si entre medias llega una actualización desde el servidor o un temporizador modifica el árbol, las rutas pueden apuntar a otro sitio y el deshacer restaurará algo que nunca existió.

🏷️

Sin semantica

Un parche dice reemplazar en tal ruta, nunca dice el usuario renombró el documento. Para etiquetar un paso en la interfaz, agrupar varios cambios o decidir qué es una unidad deshacible hay que añadir esa capa por encima.

🔀

Historial lineal

Aplicar dos listas de parches concurrentes producidas sobre el mismo estado base no converge: el segundo pisa o desplaza al primero. Resolver eso exige transformación operacional o CRDTs, no parches.

El primero de los tres merece un desarrollo, porque es el que produce corrupciones más difíciles de rastrear. Un inverso no es una descripción autónoma de cómo llegar a un estado anterior: es una instrucción relativa que presupone un contexto exacto. Si entre el cambio y su deshacer se cuela cualquier otra escritura sobre el mismo árbol, las rutas siguen siendo sintácticamente válidas pero apuntan a nodos distintos, y el resultado es un estado que nadie escribió nunca. La única defensa razonable es la disciplina de canalizar todas las mutaciones por el mismo punto y registrarlas todas, de modo que el historial no tenga huecos por los que se filtren cambios no contabilizados.

A esos tres se suman dos detalles operativos que conviene conocer antes de llevar el enfoque a producción. El primero es que enablePatches tiene un coste: activarlo obliga a Immer a rastrear y materializar los cambios incluso cuando nadie va a consumirlos, de modo que no se activa por comodidad sino cuando existe un consumidor real. El segundo es que las estructuras Map y Set requieren además el plugin correspondiente para producir parches correctos, y que las clases con métodos, las funciones y cualquier valor no serializable quedan fuera del contrato: un parche solo puede transportar datos.

⚠️
Un parche no es un permiso

Si los parches viajan al servidor, no pueden aplicarse tal cual sobre los datos reales. Un parche es una instrucción de escritura arbitraria sobre una ruta arbitraria, de modo que aceptarlo sin validar equivale a conceder acceso de escritura total a cualquier cliente. El servidor debe comprobar que cada ruta pertenece al documento que el usuario puede editar, que el campo es modificable y que el valor cumple el esquema, exactamente igual que haría con una petición ordinaria. La comodidad del formato no exime de la autorización.

Elegir entre los tres enfoques

Con las tres representaciones sobre la mesa —instantáneas, comandos y parches— el criterio de elección deja de ser una cuestión de gusto y se puede enunciar con precisión. La pregunta decisiva no es cuál es mejor sino qué necesita salir del proceso: si el historial solo vive en memoria y el estado es pequeño, la simplicidad de las instantáneas es imbatible y cualquier sofisticación es coste sin beneficio; si el historial tiene que significar algo —etiquetarse, auditarse, mostrarse al usuario, transformarse frente a ediciones ajenas— hacen falta comandos, porque solo ellos llevan intención; y si lo único que hace falta es que el historial sea pequeño y serializable sin escribir código extra, los parches son la respuesta directa.

// Coste por paso, en orden de magnitud
// instantaneas: tamano del estado, salvo structural sharing
// comandos:     tamano de la operacion mas lo que destruye
// parches:      tamano del cambio, derivado automaticamente

// Trabajo manual por operacion nueva del producto
// instantaneas: ninguno
// comandos:     escribir y mantener la inversa
// parches:      ninguno

Los tres son además combinables, y las aplicaciones maduras suelen mezclarlos sin conflicto: parches para el grueso de las ediciones, comandos con nombre para las operaciones que el usuario debe reconocer en la interfaz, e instantáneas periódicas como puntos de apoyo que evitan replegar un historial largo desde el origen. Lo que nunca funciona es adoptar la representación más sofisticada por anticipación, antes de tener un problema que la justifique: el historial que hay que arreglar es siempre el que se diseñó para requisitos que no llegaron.

El parche es el patron comando derivado por observacion

Lo que ocurre aquí es una de esas convergencias que revelan la estructura profunda de un problema. La lección uno guardaba valores y la dos guardaba operaciones, y parecían dos filosofías rivales entre las que había que elegir. Los parches muestran que la elección era falsa, porque el estilo de escritura y la representación del historial son dimensiones independientes que llevábamos acopladas sin darnos cuenta: puedes pensar y escribir en instantáneas —muta el borrador, describe el resultado, olvídate del delta— y almacenar en comandos, si hay una capa capaz de traducir de lo uno a lo otro. Esa capa es el Proxy, y su papel es exactamente el mismo que ya cumplía para el structural sharing: convertir tus asignaciones en un registro de intenciones y ese registro en el conjunto mínimo de operaciones necesarias. El deshacer, visto así, no es una funcionalidad que Immer añadiera, sino un subproducto que estaba ahí desde el primer día esperando a que alguien preguntara por él. Y la moraleja transferible es más amplia que Immer: cuando una parte de tu sistema ya tiene que calcular una información para hacer su trabajo, exponer esa información suele habilitar capacidades enteras a coste marginal cero. Antes de construir un mecanismo nuevo, conviene preguntarse quién en el sistema ya sabía la respuesta y simplemente no la estaba contando.

⚔️ Convierte parches en un historial de verdad
  1. Activa enablePatches y observa los parches e inversos de tres recetas distintas: cambiar un campo hoja, insertar en un array y borrar del medio de un array.
  2. Construye la pila completa con deshacer y rehacer sobre un documento anidado, y verifica que veinte pasos de ida y vuelta devuelven la igualdad profunda con el estado inicial.
  3. Elimina la inversión del array de inversos y encuentra el caso mínimo que falla. Explica en dos líneas por qué los índices son la causa.
  4. Serializa el historial a localStorage, recarga la página y comprueba que deshacer sigue funcionando. Compara el tamaño en bytes con el de un historial equivalente por instantáneas.
  5. Simula una actualización externa que modifique el documento entre un cambio y su deshacer, y documenta con precisión el estado corrupto que resulta.
  6. Genera parches en dos clientes a partir del mismo estado base, aplícalos en ambos en distinto orden y demuestra que no convergen.
  7. Escribe la conversión al formato JSON Patch estándar, con el escapado de JSON Pointer incluido, y valídala contra un ejemplo de la especificación.
  8. Diseña la validación de servidor que aceptaría o rechazaría cada parche entrante, y enumera los tres tipos de ruta que nunca deberían pasar el filtro.