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

Instantáneas: el historial como dos pilas de estados completos

El enfoque más simple para construir deshacer y rehacer consiste en no ser listo: cada vez que el estado cambia, se guarda una copia entera del estado anterior en una pila, y deshacer se reduce a sacar la copia de arriba. Esta lección formaliza esa idea ingenua y descubre que tiene una estructura precisa detrás: el triple pasado, presente y futuro es un zipper sobre la línea del tiempo, y las tres operaciones del historial —deshacer, rehacer y registrar— no son más que desplazamientos del foco dentro de esa estructura, con un invariante que decide qué ocurre cuando el usuario deshace y luego actúa. Se analiza por qué el enfoque es tan atractivo —correcto por construcción, imposible de desincronizar, trivial de auditar— y dónde está su precio real, que no es el que la intuición dice: con estado inmutable y structural sharing una instantánea no cuesta el tamaño del estado sino el tamaño del cambio, mientras que un clon profundo convierte el mismo diseño en una fuga de memoria lineal. Al final el lector sabe implementar un historial por instantáneas en veinte líneas, acotarlo y reconocer el punto exacto en que deja de ser aceptable.

⏱ 18 min

Deshacer es, junto con guardar, la función que más confianza produce en una interfaz: mientras exista, el usuario explora sin miedo, porque ningún error es definitivo. Y sin embargo casi nunca se diseña, se improvisa cuando ya hay veinte pantallas construidas y alguien pregunta si se puede añadir. El resultado suele ser un parche frágil que deshace unas cosas y otras no. Este nivel entero trata de evitar esa deriva empezando por el principio, y el principio es el enfoque más tosco que existe: guardar el estado completo antes de cada cambio y volver a él cuando haga falta. Lo llamamos instantáneas, y su virtud es que no puede desincronizarse, porque no reconstruye nada: reinstala un valor que ya fue verdad. Lo interesante es que esa ingenuidad esconde una estructura elegante —un zipper sobre el tiempo— y que su fama de derrochador es en buena medida un malentendido heredado de la época en que el estado se clonaba a mano. Si el estado es inmutable y las actualizaciones comparten estructura, la instantánea es mucho más barata de lo que su nombre sugiere. Esta lección construye el historial completo, enuncia sus invariantes y sitúa con precisión la frontera a partir de la cual hay que buscar algo mejor.

🎯 Al terminar esta lección sabrás
  • Modelar el historial como un zipper de tres piezas: pasado, presente y futuro.
  • Implementar las tres operaciones —registrar, deshacer, rehacer— respetando el invariante de la rama futura.
  • Distinguir el coste aparente de una instantánea de su coste real bajo structural sharing.
  • Reconocer los tres síntomas que indican que las instantáneas se han quedado cortas.

El zipper del tiempo: pasado, presente y futuro

Un historial no es una lista de estados: es una lista con un dedo apoyado en un punto. Esa distinción tiene nombre en programación funcional —el zipper de Huet— y consiste en representar el foco dentro de una secuencia partiéndola en tres: lo que queda a un lado, el elemento enfocado y lo que queda al otro. Aplicado al tiempo, el foco es el presente, lo que queda detrás es el pasado en orden inverso y lo que queda delante es el futuro al que podrías rehacer. La ventaja de esta representación frente a guardar un array y un índice es que las dos operaciones que más importan, deshacer y rehacer, se vuelven desplazamientos de coste constante entre las puntas de dos pilas, sin recorrer ni copiar nada.

type Historial<T> = {
  pasado: T[]     // estados anteriores, el mas reciente al final
  presente: T     // el estado visible ahora mismo
  futuro: T[]     // estados deshechos, el mas proximo al final
}

const nuevo = <T,>(inicial: T): Historial<T> => ({
  pasado: [],
  presente: inicial,
  futuro: [],
})

Conviene detenerse en el orden elegido para las dos pilas, porque es lo que hace que todo lo demás sea trivial. Tanto pasado como futuro se apilan por el final, de modo que el vecino temporal del presente está siempre en la última posición de ambos arrays. Deshacer consiste en mover el presente al futuro y traer el último elemento del pasado; rehacer es la operación simétrica. No hay índices que mantener sincronizados ni desplazamientos que recalcular: la estructura codifica la posición.

flowchart LR
P0[pasado n-2] --> P1[pasado n-1]
P1 -->|deshacer| PR[PRESENTE]
PR -->|rehacer| F1[futuro 1]
F1 --> F2[futuro 2]
style PR fill:#cba6f7,color:#11111b

Las tres operaciones y el invariante de la rama muerta

Registrar un cambio empuja el presente al pasado y coloca el estado nuevo como presente. Deshacer y rehacer trasvasan un elemento entre pilas. Las tres son puras y devuelven un historial nuevo, lo que las hace triviales de testear y de integrar en cualquier store inmutable.

function registrar<T>(h: Historial<T>, siguiente: T): Historial<T> {
  if (Object.is(siguiente, h.presente)) return h  // nada cambio, no ensucies
  return { pasado: [...h.pasado, h.presente], presente: siguiente, futuro: [] }
}

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.futuro, h.presente],
  }
}

function rehacer<T>(h: Historial<T>): Historial<T> {
  if (h.futuro.length === 0) return h
  const siguiente = h.futuro[h.futuro.length - 1]
  return {
    pasado: [...h.pasado, h.presente],
    presente: siguiente,
    futuro: h.futuro.slice(0, -1),
  }
}

La línea que decide el carácter del sistema es la que vacía futuro dentro de registrar. Ese vaciado codifica una decisión de diseño con consecuencias visibles: si el usuario deshace tres pasos y a continuación escribe algo nuevo, la rama que había deshecho se pierde para siempre. Es el comportamiento canónico en prácticamente todo el software de consumo, y su justificación es cognitiva antes que técnica: mantener viva una rama invisible obligaría al usuario a razonar sobre un árbol que no ve. La alternativa existe y es respetable —conservar la bifurcación y ofrecer un árbol de deshacer navegable, como hace Vim con su undo tree— pero cambia el modelo mental de una línea a un grafo, y esa carga solo se paga en herramientas para profesionales que la piden.

📝
Guardar antes, no despues

Un error clásico consiste en registrar la instantánea después de aplicar el cambio, con lo que la pila acaba conteniendo el estado resultante y no el previo. Entonces el primer deshacer no hace nada visible, porque restaura justo lo que ya se ve, y todo el historial queda desplazado un paso. La regla es simple: lo que se apila es siempre el presente que se está abandonando.

El coste real: compartir no es clonar

La objeción inmediata a las instantáneas es la memoria, y suele enunciarse mal. La frase habitual —guardar el estado completo en cada paso es inviable— asume que cada entrada del historial ocupa el tamaño del estado. Eso solo es cierto si copias en profundidad. Con actualizaciones inmutables reales, el estado nuevo comparte con el anterior todas las ramas que no cambiaron, de modo que apilar el presente no copia nada: apila una referencia a un árbol que ya existe, y el consumo adicional de un paso es proporcional al camino modificado, no al tamaño total. Cincuenta instantáneas de un árbol de diez mil nodos donde cada paso toca una hoja no cuestan quinientos mil nodos, sino diez mil más unos pocos cientos.

// Con actualizacion inmutable: el paso cuesta el camino, no el arbol
const siguiente = { ...estado, doc: { ...estado.doc, titulo: "otro" } }
siguiente.ajustes === estado.ajustes   // true  -> rama compartida, coste cero
siguiente.doc      === estado.doc      // false -> unico nodo realmente nuevo

// Con clon profundo: el paso cuesta el arbol entero, cada vez
const copiaCara = structuredClone(estado)  // O de n en tiempo Y en memoria

Esto reordena por completo el juicio sobre el enfoque. El enemigo no es la instantánea, es el clon. Y aun así el consumo crece de forma monótona, porque el historial retiene versiones que nadie volverá a mirar e impide que el recolector de basura libere las ramas viejas. De ahí la única disciplina imprescindible: acotar. Un historial en producción se limita a una profundidad fija —entre veinte y cien pasos cubre el noventa y nueve por ciento de los deshacer reales— descartando por la cabeza de la pila, lo que convierte el consumo en constante y elimina la clase entera de incidencias por crecimiento sin techo.

const LIMITE = 50

function acotar<T>(h: Historial<T>): Historial<T> {
  return h.pasado.length <= LIMITE
    ? h
    : { ...h, pasado: h.pasado.slice(h.pasado.length - LIMITE) }
}
⚠️
Los tres sintomas de que las instantaneas se quedaron cortas

El enfoque deja de ser adecuado cuando aparece alguno de estos tres síntomas. Primero, el estado contiene nodos grandes y no compartibles —el contenido de un lienzo, un buffer binario, un mapa de miles de entidades que se reconstruye entero en cada edición— y entonces cada paso sí paga el tamaño completo. Segundo, la frecuencia de cambio es alta y continua, como en el arrastre de un elemento o la escritura carácter a carácter, donde acotar el historial equivale a perder la mitad de los pasos útiles. Tercero, el historial tiene que viajar por la red o persistirse, porque enviar estados enteros donde bastaba con enviar diferencias multiplica el ancho de banda por varios órdenes de magnitud. Cualquiera de los tres es la señal para pasar a comandos o a parches, que son las dos lecciones siguientes.

Conectar el historial al store

Queda una decisión de arquitectura que se toma mal con frecuencia: qué porción del estado entra en el historial. La tentación es envolver el store entero, y es un error, porque arrastra al pasado cosas que no deben viajar —la sesión del usuario, la caché del servidor, los datos de conexión, el estado efímero de la interfaz—. El historial debe envolver únicamente el documento versionable, y el resto del estado debe vivir fuera de él, inmune a los saltos temporales. Separar ambas mitades desde el primer día evita una refactorización dolorosa más adelante y hace que el deshacer no tenga efectos secundarios sorprendentes.

type App = {
  historial: Historial<Doc>   // SOLO el documento entra aqui
  sesion: Sesion              // fuera del historial: no debe viajar
  cacheServidor: Cache        // fuera: la gestiona su propia capa
}

const puedeDeshacer = (a: App) => a.historial.pasado.length > 0
const puedeRehacer  = (a: App) => a.historial.futuro.length > 0
const docActual     = (a: App) => a.historial.presente

Con esa separación hecha, exponer la funcionalidad es trivial y conviene hacerlo de forma completa desde el principio: los dos predicados anteriores alimentan el estado deshabilitado de los botones, el atajo de teclado se conecta a las mismas funciones puras y la interfaz nunca lee el historial directamente sino a través del selector docActual. Ese último punto importa más de lo que parece, porque mantiene a todos los componentes ignorantes de que existe un historial: para ellos solo hay un documento, y el día que se cambie el enfoque de instantáneas a parches ninguno tendrá que enterarse.

ℹ️
El historial es infraestructura, no dominio

Un síntoma fiable de que el historial está bien colocado es que ningún componente de la interfaz menciona las palabras pasado o futuro. Si aparecen repartidas por la aplicación, el historial ha dejado de ser una capa y se ha convertido en un acoplamiento, y a partir de ahí cada funcionalidad nueva tendrá que acordarse de registrar sus cambios a mano, que es exactamente el modo en que estos sistemas empiezan a olvidarse pasos.

La instantanea convierte el tiempo en una estructura de datos

Lo que hace profundo a este enfoque no es su implementación, que cabe en veinte líneas, sino la reubicación conceptual que propone: el tiempo deja de ser una propiedad del entorno y pasa a ser un dato que tú posees. Un programa mutable no puede deshacer porque el pasado no existe en ninguna parte, ha sido sobrescrito y no queda de él más que la ausencia; un programa que produce valores nuevos en lugar de modificar los viejos tiene el pasado literalmente en la mano, y deshacer se vuelve una operación de lectura sobre una estructura ordinaria. Por eso las instantáneas son la primera lección de este nivel y no una nota histórica: demuestran que la capacidad de viajar hacia atrás no es una funcionalidad que se añade sino una consecuencia gratuita de una decisión tomada mucho antes, la de tratar el estado como valor. Toda la sofisticación que viene después —comandos, parches, agrupación, replay— son optimizaciones sobre esta idea, no sustituciones de ella. Y de ahí se sigue el criterio práctico que conviene grabar: si tu arquitectura hace que añadir deshacer sea difícil, el problema no está en el deshacer, está en que en algún punto dejaste de tratar el estado como valor y empezaste a tratarlo como territorio. Arreglar eso arregla también otras diez cosas que todavía no sabes que están rotas.

⚔️ Construye el historial y mide su coste de verdad
  1. Implementa el Historial completo con las tres operaciones y verifica el invariante clave: deshacer dos veces, registrar un cambio nuevo y comprobar que futuro ha quedado vacío.
  2. Escribe una prueba de ida y vuelta: para cualquier secuencia de registros, aplicar deshacer tantas veces como registros hubo debe devolver exactamente el estado inicial por identidad referencial.
  3. Construye un estado con dos ramas grandes, modifica solo una rama cincuenta veces y confirma con === que las cincuenta instantáneas comparten la rama intacta.
  4. Repite el experimento sustituyendo la actualización inmutable por structuredClone y compara el consumo de memoria con el perfilador del navegador.
  5. Añade el acotado a cincuenta pasos y decide de forma razonada qué debe ocurrir con el elemento descartado en una aplicación que además persiste el historial en disco.
  6. Diseña sobre papel la variante en árbol que no descarta la rama deshecha y argumenta en tres líneas por qué casi ninguna aplicación de consumo la ofrece.