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

Time travel debugging: la misma técnica al servicio del desarrollador

La maquinaria construida durante todo el nivel para que el usuario deshaga sirve, sin cambiar una línea, para que el desarrollador se mueva por la historia de una ejecución. Esta lección cierra el nivel distinguiendo con precisión dos cosas que la industria confunde a diario: deshacer es semántico, local y selectivo, mientras que el viaje en el tiempo es mecánico, global y no interpreta nada, porque se limita a replegar el reductor sobre un prefijo distinto del registro de acciones. De esa diferencia salen sus tres condiciones de posibilidad —pureza de la transición, serializabilidad del estado y de las acciones, y determinismo del cálculo— y su límite estructural, que ninguna herramienta podrá levantar jamás: el estado vuelve atrás pero los efectos ya ocurrieron, y el correo enviado, la fila insertada y el evento de analítica registrado no se recomputan. Termina situando el valor real de la técnica, que no es la animación del deslizador sino el diagnóstico: exportar el registro de acciones de una sesión que falló y reproducirlo en local convierte un informe de error irreproducible en un caso de prueba.

⏱ 20 min

Todo lo construido en este nivel tenía un destinatario claro: la persona que usa la aplicación y quiere retroceder. Esta última lección cambia el destinatario sin cambiar la maquinaria. Si el estado se produce aplicando una función pura sobre una secuencia de acciones registradas, entonces cualquier instante pasado de esa ejecución es reconstruible a voluntad, y quien más partido puede sacarle no es el usuario sino quien depura. La demostración que popularizó la idea dura quince segundos y es engañosamente mágica: alguien arrastra un deslizador y la interfaz retrocede sola. Conviene desmontar la magia cuanto antes, porque de ella depende saber cuándo se puede confiar en la herramienta. No hay viaje: hay recálculo. Nada retrocede, nada se deshace, no se restaura ningún estado guardado; simplemente se vuelve a plegar la misma función de transición sobre el estado inicial tomando un prefijo más corto de la lista de acciones, y la interfaz obedece porque es una función del estado y no tiene memoria propia. Entender esa frase es entender a la vez por qué la técnica es tan poderosa, por qué exige una arquitectura muy concreta para funcionar, y por qué hay una clase entera de cosas que no puede devolver jamás.

🎯 Al terminar esta lección sabrás
  • Distinguir deshacer, que es semántico y local, de viajar en el tiempo, que es mecánico y global.
  • Enunciar las tres condiciones de posibilidad: pureza, serializabilidad y determinismo.
  • Aislar las fuentes de indeterminación para que el replay reconstruya el mismo estado.
  • Delimitar qué no vuelve nunca y usar el registro de acciones como herramienta de diagnóstico.

Dos operaciones que se confunden

Deshacer y viajar en el tiempo se parecen en la pantalla y no se parecen en nada por dentro. Deshacer es una operación del producto: es selectiva, porque retira solo los cambios propios del usuario y respeta los ajenos; es semántica, porque un paso corresponde a una acción percibida y no a un cambio técnico; y es local, porque afecta a un documento y deja fuera la sesión, la configuración y la caché. Viajar en el tiempo es una operación de la herramienta: es global, porque mueve el estado entero de la aplicación a la vez; es mecánica, porque no interpreta el significado de nada; y es no destructiva, porque el registro completo sigue ahí y se puede volver adelante.

type Accion = { type: string; payload?: unknown }

// El estado en el instante n es un pliegue sobre el prefijo de longitud n
function estadoEn<S>(inicial: S, reductor: (s: S, a: Accion) => S, log: Accion[], n: number): S {
  return log.slice(0, n).reduce(reductor, inicial)
}

// Saltar al instante 7 no deshace nada: recalcula desde el principio
const enSiete = estadoEn(inicial, reductor, log, 7)

De esa definición se sigue de inmediato la segunda capacidad, menos vistosa y mucho más útil que el deslizador: si el estado es un pliegue sobre una lista, se puede quitar un elemento del medio y volver a plegar. Eso permite desactivar una acción concreta y ver cómo habría quedado el mundo sin ella, que es la forma más directa de contrastar una hipótesis sobre el origen de un fallo. No estás inspeccionando el pasado: estás construyendo pasados alternativos y comparándolos.

flowchart LR
I[estado inicial] --> R[reductor puro]
L[registro de acciones] --> R
R --> N{cuantas acciones aplicas}
N -->|prefijo corto| A[instante pasado]
N -->|prefijo completo| B[instante actual]
N -->|prefijo con una accion desactivada| C[pasado alternativo]
style R fill:#cba6f7,color:#11111b
style C fill:#a6e3a1,color:#11111b

Las tres condiciones de posibilidad

La técnica no funciona en cualquier arquitectura. Exige tres propiedades, y cada una de ellas es también, por sí sola, una buena práctica que conviene sostener aunque nunca se abra una herramienta de depuración temporal.

🧼

Pureza de la transicion

La función que produce el estado siguiente depende únicamente del estado anterior y de la acción. Si además lee del reloj, del almacenamiento o de una variable de módulo, replegar el registro no reproduce la ejecución sino una ejecución distinta.

📦

Serializabilidad

Estado y acciones deben ser datos planos. Clases con métodos, funciones, promesas o referencias al DOM dentro del estado impiden guardar el registro, exportarlo y volver a cargarlo, que es donde reside casi todo el valor práctico.

🎲

Determinismo

Dos ejecuciones del mismo registro deben dar el mismo resultado. Identificadores aleatorios, marcas de tiempo y cualquier lectura del entorno introducen divergencia y hacen que el replay se separe poco a poco de la sesión original.

La tercera es la que se incumple con más frecuencia y la que se arregla con una disciplina única: la indeterminación se calcula en el borde y se transporta dentro de la acción, nunca se genera dentro de la transición. Es exactamente la misma disciplina que la lección dos exigía a los comandos para que rehacer reconstruyera el estado original, y no es casualidad: ambos problemas son el mismo problema visto desde ángulos distintos.

// Mal: la transicion inventa datos, el replay produce otro estado
function reductorMalo(s: Estado, a: Accion): Estado {
  if (a.type === "crear") {
    return { ...s, items: [...s.items, { id: crypto.randomUUID(), creado: Date.now() }] }
  }
  return s
}

// Bien: la accion transporta lo indeterminado, la transicion es un calculo puro
const crear = () => ({ type: "crear", payload: { id: crypto.randomUUID(), creado: Date.now() } })

Hay un detalle de implementación que conviene anticipar porque decide el rendimiento de toda la herramienta. Replegar el registro completo en cada movimiento del deslizador es correcto pero cuadrático, y en una sesión con miles de acciones la interfaz se vuelve inusable. La solución es la misma que apareció al final de la lección dos: guardar instantáneas periódicas del estado como puntos de apoyo y plegar únicamente desde el más cercano hacia atrás. Es la única memoización que hace falta y convierte el coste de un salto en constante amortizado.

// Un punto de apoyo cada k acciones vuelve constante el coste del salto
const K = 25

function estadoEnRapido<S>(
  apoyos: Map<number, S>, reductor: (s: S, a: Accion) => S, log: Accion[], n: number, inicial: S
): S {
  const base = Math.floor(n / K) * K
  const desde = apoyos.get(base) ?? inicial
  return log.slice(base, n).reduce(reductor, desde)
}

Donde el viaje se rompe

Aquí está el límite que ninguna herramienta futura levantará, porque no es una carencia de implementación sino una propiedad del mundo. El pliegue reconstruye el estado, y solo el estado. Todo lo que la aplicación hizo fuera de su propia memoria permanece hecho. La petición se envió y el servidor la procesó; la fila se insertó en la base de datos; el correo llegó a su destinatario; el evento de analítica quedó registrado; la escritura en el almacenamiento local persiste; la notificación se mostró. Retroceder el deslizador no cancela nada de eso, y la interfaz puede quedar mostrando un pasado coherente consigo mismo pero completamente desalineado con el mundo exterior.

⚠️
La frontera del viaje coincide con la frontera de lo puro

Hay una regla que resume todo lo anterior y que conviene memorizar: vuelve atrás exactamente lo que es función pura del estado, y no vuelve nada de lo que fue efecto. Por eso una arquitectura que separa con nitidez el cálculo del efecto obtiene el viaje en el tiempo casi gratis, y una que los mezcla no lo obtiene a ningún precio. Dicho de otro modo, la calidad del viaje en el tiempo en tu aplicación es una medición directa de cuánto has conseguido empujar los efectos hacia los bordes. No es una función que se instala: es un diagnóstico que se aprueba.

A eso se añaden dos límites prácticos. El primero es el coste: guardar cada acción con su instantánea asociada consume memoria en proporción al tamaño del estado por el número de acciones, y en sesiones largas con estados grandes la herramienta se convierte en el cuello de botella que estaba ayudando a diagnosticar. Por eso las implementaciones serias acotan el registro y permiten filtrar acciones de alta frecuencia. El segundo es la fragilidad frente al cambio de código: un registro grabado con una versión del reductor no reproduce nada fiable con otra versión distinta, de modo que los registros exportados envejecen con el despliegue y deben guardarse acompañados de la referencia exacta del código que los generó.

Con esos límites en la mano, el valor real de la técnica se ve mejor. No es el deslizador, que impresiona en una demostración y se usa poco. Es que el registro de acciones es una descripción completa, compacta y legible de lo que un usuario hizo, y capturarlo cuando algo falla convierte el peor tipo de incidencia —la que no se reproduce— en un caso de prueba determinista que se ejecuta en local y se adjunta a una regresión.

// El registro exportado es el informe de error mas util que existe
function exportarSesion(inicial: unknown, log: Accion[], version: string) {
  return JSON.stringify({ version, inicial, log })
}
// Reproducirlo en local: mismo inicial, mismo log, mismo reductor, mismo fallo

Cuando el replay no basta: grabar la ejecución

El pliegue sobre el registro de acciones es la forma barata de viajar en el tiempo, y su precio es la exigencia arquitectónica: solo funciona si tu aplicación ya está organizada como estado más transiciones puras. Existe una familia distinta de herramientas que consigue lo mismo sin pedirte nada a cambio, y conviene conocerla porque resuelve justo los casos donde la anterior se rinde. En lugar de reconstruir el estado recalculándolo, graban la ejecución entera a bajo nivel, incluyendo todas las fuentes de indeterminación —el reloj, el generador aleatorio, las respuestas de red, el orden de los eventos— y después la reproducen inyectando exactamente los mismos valores. El depurador de reproducción de Mozilla y las herramientas comerciales de grabación de sesiones del navegador funcionan así.

🧮

Replay por recalculo

Barato, ligero y transparente: solo guardas acciones. Exige pureza, serializabilidad y determinismo, y no captura nada que ocurra fuera del reductor. Es lo que ofrecen las herramientas de desarrollo de un store unidireccional.

📼

Grabacion de la ejecucion

No exige nada de tu arquitectura y captura la indeterminación real, incluidos los efectos y el orden de los eventos. A cambio, la grabación es pesada, ralentiza la ejecución y no produce un artefacto legible que puedas leer como una lista de intenciones.

La comparación deja clara la naturaleza del intercambio. El recálculo te da un artefacto que además de reproducir el fallo lo explica, porque una lista de acciones con nombre es legible por un humano y revela la secuencia de intenciones que llevó al problema; la grabación te da fidelidad total sobre una ejecución concreta, pero lo que obtienes es una película, no un guion. En un equipo que controla su arquitectura, el recálculo es casi siempre la mejor inversión, porque el esfuerzo que exige —empujar los efectos a los bordes, mantener el estado serializable— es un esfuerzo que paga dividendos en otras cinco cosas. La grabación es la herramienta correcta cuando el código no es tuyo, cuando el fallo vive en una integración con terceros o cuando reproducir depende de una condición de carrera que ningún registro de acciones captura.

ℹ️
El registro tambien sirve antes de que haya un fallo

Reproducir sesiones no es solo un recurso de emergencia. Un registro de acciones representativo de un flujo real es material excelente para pruebas de regresión automáticas, para medir el rendimiento de una refactorización del reductor sobre una carga verosímil y para validar una migración de esquema del estado antes de desplegarla. Guardar unos cuantos registros canónicos junto al código es una de esas prácticas baratas cuyo valor se descubre el día que hace falta.

El viaje en el tiempo es un examen de tu arquitectura, no una funcion

Cierra el nivel una idea que reorganiza retroactivamente las cinco lecciones. Empezamos guardando estados completos, seguimos guardando operaciones con su inversa, luego derivando esas operaciones por observación, después decidiendo qué cuenta como un paso, y terminamos aquí, con un desarrollador moviéndose por una ejecución pasada. Parecen cinco técnicas y son cinco síntomas de una sola decisión anterior a todas ellas: si el estado es un valor producido por una función pura a partir de un historial explícito, entonces el pasado existe, es un dato, y viajar por él es leerlo. Si el estado es un territorio que se sobrescribe, el pasado no está en ninguna parte y ninguna librería lo va a inventar. Por eso la pregunta de si tu aplicación soporta viaje en el tiempo no es una pregunta sobre herramientas de depuración: es la forma más rápida de averiguar si tus transiciones son puras, si tus datos son serializables, si tus efectos están en los bordes y si tu historial es explícito. Un equipo que responde que sí a las cuatro tiene, además, deshacer barato, sincronización posible, auditoría gratuita y fallos reproducibles, porque las cuatro cosas se apoyan en la misma base. Y un equipo que responde que no rara vez tiene solo este problema. El viaje en el tiempo no arregla la arquitectura: la revela.

⚔️ Convierte tu historial en una herramienta de diagnostico
  1. Implementa estadoEn sobre un reductor propio y verifica que replegar el registro completo reproduce exactamente el estado actual por igualdad profunda.
  2. Añade el desactivado selectivo: quita una acción del medio, vuelve a plegar y describe qué hipótesis te permite contrastar ese pasado alternativo.
  3. Introduce a propósito una llamada al reloj dentro del reductor y mide cuánto se separa el replay de la ejecución original. Corrígelo moviendo la indeterminación a la acción.
  4. Mete en el estado un valor no serializable, intenta exportar el registro y documenta el fallo exacto que obtienes.
  5. Exporta un registro con la versión del código, recárgalo en otra pestaña y reproduce el fallo. Después modifica el reductor y comprueba que el mismo registro ya no es fiable.
  6. Enumera los efectos que tu aplicación produce durante un flujo de compra y clasifica cada uno según vuelva atrás con el estado o quede fuera del alcance del viaje para siempre.
  7. Implementa los puntos de apoyo cada veinticinco acciones y compara el tiempo de un salto con y sin ellos sobre un registro de dos mil acciones.
  8. Escribe una prueba de regresión que cargue un registro guardado, lo repliegue por completo y compare el resultado con la instantánea esperada.