wandres.dev
PATRONES AVANZADOS · undo, optimista, entity

Deshacer y rehacer: una pila de pasados, un presente, una pila de futuros

Deshacer es la funcionalidad que más miedo da implementar sobre objetos mutables y una de las más baratas sobre valores inmutables. Esta lección construye el modelo canónico del historial —pasado, presente y futuro—, lo implementa como un enhancer que envuelve cualquier reducer sin que este se entere de nada, y después ataca las decisiones que el modelo deja abiertas y que separan una demo de un producto: qué acciones merecen entrada en la historia, cómo fusionar cien pulsaciones de teclado en un solo paso deshacible, cuánto ocupa realmente la pila cuando el estado es grande, y por qué guardar acciones invertibles en lugar de instantáneas es más elegante y bastante más frágil.

⏱ 18 min

Deshacer es la prueba de esfuerzo de una arquitectura de estado. En una aplicación construida sobre objetos mutables, implementarlo obliga a escribir para cada operación su inversa exacta y a mantener ambas sincronizadas para siempre: el coste crece con el número de funcionalidades, los fallos son silenciosos, y cada operación nueva que alguien olvide invertir deja un agujero que solo se descubre cuando un usuario pierde trabajo. En una aplicación donde cada cambio produce un valor nuevo y deja el anterior intacto, el problema no se resuelve mejor: desaparece por sustitución. No hay nada que invertir, porque el estado anterior nunca se destruyó; basta con no tirarlo. Esta lección desarrolla esa observación hasta el final, porque la parte trivial ocupa quince líneas y todo lo interesante empieza justo después de escribirlas.

🎯 Al terminar esta lección sabrás
  • Modelar el historial como pasado, presente y futuro, y explicar por qué la inmutabilidad vuelve el problema casi gratuito.
  • Implementar undo y redo como un enhancer que envuelve cualquier reducer sin modificarlo.
  • Decidir qué acciones entran en la historia y cómo fusionar varias en un único paso deshacible.
  • Evaluar el coste en memoria de las instantáneas frente a la alternativa de guardar acciones invertibles.

La historia como estructura de datos

El modelo que ha sobrevivido a veinte años de implementaciones es de una simplicidad casi insultante: el estado deja de ser un valor y pasa a ser un registro de tres campos. Una lista de estados pasados en orden cronológico, el estado presente, y una lista de estados futuros que existen solo porque alguien deshizo algo. Deshacer significa mover el presente a la cabeza de los futuros y sacar el último pasado para ponerlo de presente. Rehacer hace exactamente lo mismo en la dirección contraria. Cualquier otra acción se procesa con normalidad, empuja el presente anterior al final de los pasados, y vacía los futuros, porque en cuanto el usuario hace algo nuevo la rama que había abandonado deja de ser alcanzable.

Ese vaciado de los futuros es la única decisión de diseño no obvia del modelo, y merece detenerse en ella. Lo que estás construyendo es un árbol de estados en el que el usuario camina, pero decides representarlo como una línea. Cuando alguien deshace tres pasos y luego escribe algo distinto, has creado una bifurcación: la rama vieja sigue siendo un estado perfectamente válido que existió, pero se vuelve inalcanzable porque tu estructura solo guarda un futuro. Existen editores que conservan el árbol completo y ofrecen navegación entre ramas —Vim y Emacs lo hacen desde hace décadas— y la diferencia con la línea única no es de implementación sino de modelo mental del usuario. Elegir la línea no es una limitación técnica: es una decisión de producto que el noventa y nueve por ciento de las interfaces toma correctamente.

interface Historial<T> {
  pasado: T[]
  presente: T
  futuro: T[]
}

// Todo el algoritmo cabe aqui. No hay inversas, no hay parches, no hay diffs.
function deshacer<T>(h: Historial<T>): Historial<T> {
  if (h.pasado.length === 0) return h
  const anterior = h.pasado[h.pasado.length - 1]
  return {
    pasado: h.pasado.slice(0, -1),
    presente: anterior,
    futuro: [h.presente, ...h.futuro],
  }
}

La razón por la que este código es correcto sin esfuerzo es que los valores que guarda nadie los va a modificar. En un mundo mutable, meter una referencia al estado anterior en una lista no guarda nada: guarda un puntero a un objeto que la siguiente operación va a alterar bajo tus pies, y al deshacer recuperas el estado actual disfrazado de estado antiguo. La inmutabilidad convierte una referencia en un valor, y guardar valores en una lista es la operación más elemental de la programación. Todo el peso conceptual de la funcionalidad lo carga una propiedad que ya habías decidido tener por otros motivos.

El enhancer: envolver el reducer sin tocarlo

La implementación idiomática no modifica ningún reducer existente. Es una función de orden superior que recibe un reducer y devuelve otro cuyo estado es el registro de tres campos, delegando en el original todo lo que no sea navegación por la historia. El reducer envuelto no sabe que existe un historial, sus tests siguen valiendo sin cambios, y el día que decidas quitar la funcionalidad basta con dejar de envolver. Es el mismo principio de composición que gobierna el middleware, aplicado a la capa de reducción en vez de a la de despacho.

import type { Reducer, AnyAction } from '@reduxjs/toolkit'

const DESHACER = 'historial/deshacer'
const REHACER = 'historial/rehacer'

export function conHistorial<T>(
  reducer: Reducer<T>,
  opciones: { limite: number; ignorar: (a: AnyAction) => boolean },
): Reducer<Historial<T>> {
  return (estado, accion) => {
    if (estado === undefined) {
      return { pasado: [], presente: reducer(undefined, accion), futuro: [] }
    }
    if (accion.type === DESHACER) return deshacer(estado)
    if (accion.type === REHACER) return rehacer(estado)

    const siguiente = reducer(estado.presente, accion)
    // Si el reducer devolvio la misma referencia, no paso nada digno de historia.
    if (siguiente === estado.presente) return estado
    if (opciones.ignorar(accion)) return { ...estado, presente: siguiente }

    const pasado = [...estado.pasado, estado.presente].slice(-opciones.limite)
    return { pasado, presente: siguiente, futuro: [] }
  }
}

Repara en la comprobación de identidad referencial antes de tocar la historia. Un reducer bien escrito devuelve el mismo objeto cuando una acción no le afecta, y esa igualdad de referencias —que en un mundo inmutable equivale a igualdad de valor— te da gratis el filtro más importante: las acciones que no cambian nada no ensucian la pila. Sin esa línea, cada acción irrelevante añade una entrada indistinguible de la anterior y el usuario tiene que pulsar deshacer siete veces para ver un cambio en pantalla, que es el síntoma clásico de un historial mal filtrado.

🗂️

Pasado

Lista cronológica de estados ya superados. Se recorta por la cabeza cuando supera el límite, porque lo más antiguo es lo primero que deja de importar.

📍

Presente

El único estado que la interfaz lee. Todo lo demás es historia inerte que nadie renderiza.

🔮

Futuro

Solo se puebla al deshacer, y se vacía en cuanto llega una acción nueva. Es la rama abandonada del árbol.

🧱

Enhancer

Envuelve cualquier reducer sin modificarlo. El reducer de dominio nunca sabe que su pasado se está guardando.

Qué merece entrar en la historia

Aquí es donde el problema deja de ser técnico. La lista de acciones de una aplicación real mezcla tres poblaciones muy distintas: los cambios que el usuario percibe como suyos y espera poder revertir, los cambios de interfaz que nadie querría deshacer —abrir un panel, cambiar de pestaña, mover el foco— y las llegadas de datos del servidor, que no son acciones del usuario en absoluto. Meterlo todo en la pila produce un undo inutilizable; el criterio correcto es que una entrada de historial debe corresponder a una intención del usuario, no a una transición del programa.

// La historia guarda intencion del usuario, no transiciones del programa.
const esIntencion = (a: AnyAction) =>
  a.type.startsWith('documento/') && !a.meta?.remota && !a.meta?.desdeServidor

const ignorar = (a: AnyAction) => !esIntencion(a)

Las llegadas del servidor merecen párrafo aparte porque su tratamiento ingenuo produce un bug memorable. Si una respuesta de red inserta una entrada en la historia y el usuario pulsa deshacer, acaba de borrar datos que el servidor le acababa de enviar, y su estado local diverge del remoto sin que nadie se entere. Peor todavía: si además la aplicación sincroniza en sentido contrario, ese deshacer se propaga como una escritura. La regla práctica es que la historia guarda el estado que el usuario posee y edita, nunca la caché de lo que el servidor le ha prestado, y esa separación limpia es otro argumento a favor de no mezclar estado de servidor y estado de cliente en la misma rama del store.

💡
Fusionar pulsaciones: el paso deshacible como unidad semántica

Un campo de texto despacha una acción por tecla. Sin fusión, escribir una frase de cuarenta caracteres genera cuarenta entradas y deshacer se convierte en un borrado letra a letra que ningún editor serio ofrece. La técnica estándar es agrupar acciones consecutivas del mismo tipo sobre el mismo objetivo dentro de una ventana temporal —entre quinientos y mil milisegundos— reemplazando la última entrada en vez de añadir una nueva. La ventana se rompe por inactividad, por un cambio de campo o por un tipo de acción distinto. El criterio que orienta el ajuste no es técnico sino perceptivo: una entrada de historial debe corresponder a lo que el usuario recuerda haber hecho, y nadie recuerda haber escrito la sexta letra de una palabra.

El coste: memoria, estructura compartida y la tentación de las acciones

La objeción inmediata al modelo es que guardar cien estados completos parece guardar cien copias de la aplicación. No lo es, y entender por qué es entender la propiedad más valiosa de la actualización inmutable. Cuando produces un estado nuevo cambiando un campo, todo lo que no tocaste sigue siendo exactamente el mismo objeto en memoria, compartido entre ambas versiones. Cien entradas de historial sobre un estado de un megabyte no cuestan cien megabytes: cuestan un megabyte más el camino de nodos que cada cambio tuvo que reconstruir, que suele ser una fracción diminuta. Esa compartición estructural es la que hace viable el modelo entero, y la que explica que las herramientas de desarrollo puedan guardar la sesión completa sin que el navegador se resienta.

flowchart LR
P1[estado 1] --> P2[estado 2]
P2 --> A[presente]
A -->|deshacer| P2
A -->|accion nueva| N[presente nuevo]
N -.->|futuro vaciado| X[rama abandonada]
style A fill:#89b4fa,color:#11111b
style X fill:#f38ba8,color:#11111b

El límite que sí hay que vigilar no es el volumen total sino la retención. Cada entrada del historial mantiene vivos los objetos que el estado presente ya no referencia, así que borrar una imagen enorme del estado no libera su memoria mientras la pila la recuerde. Por eso el límite de entradas no es un detalle cosmético: es la única política de liberación que tiene la estructura. Cincuenta o cien pasos cubren cualquier expectativa razonable de usuario, y recortar por la cabeza al superar el límite convierte el historial en un recurso acotado en lugar de en una fuga que crece durante toda la sesión.

La alternativa clásica es guardar acciones en vez de estados: en lugar de la instantánea, apuntas qué se hizo, y deshacer significa reproducir la historia desde el principio omitiendo el último paso. Es el modelo de las herramientas de desarrollo y es teóricamente superior en memoria, porque una acción ocupa unos bytes y un estado puede ocupar bastantes más. Su precio es una condición dura: exige que todo reducer sea determinista y puro para siempre, sin excepciones. Cualquier lectura de reloj, cualquier número aleatorio, cualquier identificador generado dentro del reducer hace que la reproducción produzca un estado distinto del original, y el fallo no se manifiesta como un error sino como datos que cambian solos al deshacer.

Existe un punto medio que en la práctica gana a los dos extremos y que conviene conocer antes de elegir: guardar parches en lugar de estados completos o de acciones. La librería de inmutabilidad que ya usas puede emitir, al producir cada estado, la lista de operaciones que lo generaron y la lista que lo desharía; guardar la segunda cuesta unos bytes por entrada y deshacer consiste en aplicarla, sin reproducir nada y sin exigir pureza más allá del propio reducer. Es la misma técnica que sostiene la reversión precisa de la lección siguiente, y aquí resuelve el único inconveniente real de las instantáneas, que es el estado tan grande que ni la compartición estructural lo salva.

⚠️
Reproducir la historia amplifica cualquier impureza

Con instantáneas, un reducer impuro produce un bug local y visible. Con reproducción de acciones, el mismo reducer produce un bug que aparece únicamente al deshacer, tras un número variable de pasos, y que no se reproduce en un test unitario porque el test ejecuta la acción una sola vez. Añade a esto que reproducir doscientas acciones sobre un estado grande es trabajo síncrono en el hilo principal, y tendrás el cuadro completo: el enfoque de acciones cambia un coste de memoria predecible por un coste de CPU en el peor momento y una obligación de pureza que ningún equipo mantiene durante tres años sin una salvaguarda automática.

Deshacer no es una funcionalidad que se añade: es una propiedad que emerge de haber elegido bien la representación del cambio

Conviene mirar de frente lo que acaba de ocurrir en esta lección, porque es el argumento más limpio que existe a favor de todo lo que llevas construido. La funcionalidad de deshacer es, en el mundo mutable, uno de los problemas caros de la ingeniería de interfaces: obliga a definir para cada operación una inversa, a demostrar que la composición de inversas invierte la composición, y a mantener esa correspondencia viva mientras el producto crece, con la particularidad de que cada operación nueva puede romper la propiedad sin que ningún test lo note. Es la clase de requisito que llega tarde en la vida de un producto y que, cuando llega, obliga a reescribir la capa de datos entera. En el mundo inmutable ese problema no se resuelve: deja de existir, y lo hace porque la representación que elegiste para el cambio ya contenía la respuesta. Al decidir que cambiar significa producir un valor nuevo en vez de alterar uno existente, decidiste sin saberlo que la aplicación conservaría la totalidad de su pasado como efecto colateral, y deshacer se convierte en el acto de dejar de tirar algo que ya tenías. Ahí está la lección general que este nivel entero va a repetir con distintos disfraces: la diferencia entre una arquitectura y otra casi nunca está en lo que cada una permite hacer, porque cualquier cosa se puede hacer en cualquier sitio con suficiente esfuerzo, sino en qué requisitos futuros llegarán como una tarde de trabajo y cuáles llegarán como una reescritura. Deshacer, colaboración, sincronización entre pestañas, viaje en el tiempo, auditoría, reproducción de sesiones de usuario: todos son consecuencias baratas de una misma decisión de representación tomada mucho antes de que nadie los pidiera. Y por eso el criterio para juzgar una arquitectura no puede ser cómo de bien resuelve los problemas que ya conoces —para eso siempre hay atajos más cortos— sino cuántos de los problemas que aún no conoces vuelve triviales. La inmutabilidad no es disciplina por higiene: es la compra anticipada de una opción sobre el futuro, y deshacer es la primera vez que la ejerces.

⚔️ Construye un historial que aguante un usuario real
  1. Escribe el enhancer conHistorial con las tres operaciones y móntalo sobre un slice existente sin modificar ni una línea del reducer original; comprueba que sus tests siguen pasando.
  2. Añade el filtro por identidad referencial y verifica con un contador que las acciones que no cambian el estado no generan entradas.
  3. Clasifica todas las acciones de tu slice en tres grupos: intención del usuario, ruido de interfaz y llegada de datos del servidor. Justifica por escrito la asignación de cada una y aplícala en la función que decide qué ignorar.
  4. Implementa la fusión de acciones consecutivas del mismo tipo dentro de una ventana temporal y ajústala escribiendo una frase larga en un campo de texto hasta que deshacer borre palabras y no letras.
  5. Mide el tamaño del historial en memoria con cien entradas y compáralo con cien veces el tamaño de un estado; explica la diferencia en términos de compartición estructural.
  6. Introduce deliberadamente una impureza en el reducer —un identificador aleatorio— y razona qué le ocurriría a un historial basado en reproducción de acciones frente al tuyo basado en instantáneas.