wandres.dev
HISTORIA Y FINALES · recordar y terminar

Estados de historia: shallow y deep

Un estado compuesto olvida su interior en cuanto lo abandonas. El pseudoestado de historia cancela ese olvido: al reentrar, restaura el último subestado que estuvo activo en lugar del inicial. Esta lección disecciona la mecánica exacta en XState v5 —cuándo se graba la memoria, cuándo se resuelve, por qué un nodo de historia nunca llega a estar activo—, separa la profundidad superficial de la profunda con el criterio que decide entre ambas, explica el destino por defecto de la primera reentrada y muestra dónde vive físicamente esa memoria dentro del snapshot y qué consecuencias tiene para la persistencia.

⏱ 18 min

Salir de un estado compuesto es destruir su configuración interna. No hay medias tintas: al ejecutar la transición de salida, la máquina abandona la hoja, luego su padre, luego el padre del padre, disparando cada exit por el camino, y lo que quedaba activo dentro deja de existir. Volver a entrar es empezar de cero por el subestado inicial. Ese olvido es una virtud casi siempre —un diálogo reabierto debe arrancar limpio, un formulario cancelado no debería resucitar a medias— pero hay una familia entera de interacciones donde es exactamente lo contrario de lo que el usuario espera: el reproductor que se pausa por una llamada, el panel de ajustes que navega a otra pantalla, el asistente que se interrumpe. Harel resolvió el problema en 1987 con un mecanismo estructural en vez de una convención de código: el pseudoestado de historia, un nodo que no es un estado donde detenerse sino una instrucción de reentrada que dice “vuelve a donde estabas”. Y como los compuestos anidan, la instrucción viene en dos profundidades que conviene no confundir jamás.

🎯 Al terminar esta lección sabrás
  • Explicar cuándo se graba la memoria de historia y cuándo se resuelve al reentrar.
  • Distinguir la profundidad superficial de la profunda por el tramo de camino que restauran.
  • Declarar un nodo de historia en XState v5 con su destino por defecto para la primera entrada.
  • Localizar la memoria de historia dentro del snapshot y razonar sobre su persistencia.

El nodo que nunca está activo

Un nodo de historia se declara como un hijo más del estado compuesto, con type: 'history'. Pero es un pseudoestado, no un estado: la máquina jamás reposa en él. Cuando una transición lo apunta como destino, el motor lo resuelve en el mismo microstep —consulta la memoria grabada, la sustituye por el subestado real memorizado y entra allí— sin que la configuración activa llegue a contenerlo ni un instante.

import { createMachine } from 'xstate'

const editor = createMachine({
  id: 'editor',
  initial: 'trabajando',
  states: {
    trabajando: {
      initial: 'redaccion',
      states: {
        redaccion: {
          initial: 'escribiendo',
          states: {
            escribiendo: { on: { PREVISUALIZAR: 'preview' } },
            preview: { on: { EDITAR: 'escribiendo' } },
          },
        },
        terminal: { on: { CERRAR: 'redaccion' } },
        hist: { type: 'history', history: 'deep', target: 'redaccion' },
      },
      on: { TERMINAL: '.terminal', BLOQUEAR: 'bloqueado' },
    },
    bloqueado: {
      on: { DESBLOQUEAR: 'trabajando.hist' },
    },
  },
})

La secuencia temporal importa más que la sintaxis. La memoria se graba al salir: en el momento en que trabajando se desactiva, el motor anota qué configuración tenía dentro. Se consulta al entrar, y solo si la transición apunta explícitamente al nodo de historia; una transición a trabajando a secas ignora la memoria y usa el inicial, aunque el nodo de historia exista. Historia y initial conviven sin estorbarse porque responden a preguntas distintas: initial dice por dónde empezar cuando nadie pide otra cosa, la historia dice por dónde continuar cuando alguien la invoca.

stateDiagram-v2
[*] --> Trabajando
state Trabajando {
  [*] --> Redaccion
  state Redaccion {
    [*] --> Escribiendo
    Escribiendo --> Preview: previsualizar
    Preview --> Escribiendo: editar
  }
  Redaccion --> Terminal: terminal
  Terminal --> Redaccion: cerrar
}
Trabajando --> Bloqueado: bloquear
Bloqueado --> Trabajando: desbloquear via historia
note right of Bloqueado: el nodo de historia se resuelve al entrar y nunca queda activo
💡
La primera vez no hay nada que recordar

Todo nodo de historia arrastra un problema de arranque: la primera vez que una transición lo apunta, aún no se ha registrado ninguna salida previa. Para ese caso admite un target propio, que actúa como destino por defecto mientras la memoria esté vacía; si no lo declaras, el motor cae en el initial del compuesto. A partir de la primera salida real la memoria ya tiene contenido y el target deja de intervenir para siempre. Es la misma semántica que un parámetro con valor por defecto: solo se usa mientras nadie ha proporcionado uno real, y conviene declararlo explícito porque el destino inicial de un flujo interrumpible rara vez coincide por casualidad con el initial estructural.

Superficial contra profunda

La historia recuerda, pero hay que decidir hasta qué profundidad. Como los compuestos anidan, la respuesta no es única, y elegir mal produce el bug más silencioso de esta familia: uno que solo se manifiesta cuando el subestado recordado es a su vez compuesto.

🪶

Superficial

Con history: 'shallow' se recuerda solo el hijo directo. Restaura redaccion, pero deja que arranque por SU inicial. Recuerda qué sección, no dónde dentro de ella.

🪂

Profunda

Con history: 'deep' se recuerda el camino entero hasta la hoja. Restaura redaccion.preview clavado. Recuerda la configuración anidada completa.

🔀

Indistinguibles

Si el subestado recordado es una hoja, ambas profundidades coinciden. No hay camino extra que guardar, así que la elección solo se nota bajo anidamiento real.

La tabla siguiente recorre la misma máquina tres veces, saliendo desde tres posiciones distintas, y muestra dónde aterriza cada profundidad al volver. Las dos primeras filas son las que separan los mecanismos; la tercera es la que engaña, porque parece confirmar que la elección da igual.

Posición al salir Vuelta con shallow Vuelta con deep
redaccion.escribiendo redaccion.escribiendo redaccion.escribiendo
redaccion.preview redaccion.escribiendo redaccion.preview
terminal terminal terminal

El criterio que decide es una sola pregunta, formulada desde el usuario y no desde el árbol: cuando vuelve, ¿le basta con la misma sección o exige el mismo estado interno completo? Volver a la pestaña de facturación con sus campos recién montados es superficial. Volver a un reproductor con el mismo modo de subtítulos y el mismo panel abierto es profunda. Cuando dudes, mira qué le resultaría molesto al usuario perder: si lo que perdería es información que él mismo produjo dentro de la sección, quieres profunda.

Un matiz que se pasa por alto: history: 'shallow' es el valor por defecto si escribes type: 'history' sin más. Esa elección de la librería es conservadora y sensata, porque la memoria superficial es más barata de razonar y sorprende menos, pero significa que la profundidad que casi siempre quieres en un asistente interrumpible es la que tienes que pedir explícitamente. Omitir la propiedad no es neutral: es elegir superficial en silencio.

Dónde vive la memoria

La historia no es una idea abstracta del motor: es un campo del snapshot. En XState v5, snapshot.historyValue guarda un registro de qué configuraciones se memorizaron para qué nodos de historia. Verlo como dato explica de golpe tres propiedades que de otro modo parecen mágicas.

import { createActor } from 'xstate'

const actor = createActor(editor).start()
actor.send({ type: 'PREVISUALIZAR' })
actor.send({ type: 'BLOQUEAR' })

const snap = actor.getSnapshot()
snap.value          // 'bloqueado'
snap.historyValue   // registro con la configuracion memorizada de trabajando

const guardado = actor.getPersistedSnapshot()
// guardado viaja a localStorage o al servidor con la historia dentro

Verla como dato tiene además una consecuencia práctica inmediata en depuración. Cuando un flujo interrumpible vuelve al sitio equivocado, la pregunta no es abstracta: imprime historyValue justo antes de la reentrada y compáralo con lo que esperabas que se hubiera memorizado. Casi siempre el registro está vacío —porque nadie llegó a salir del compuesto, sino de un ancestro que lo contenía y que tiene su propia memoria— o contiene la configuración correcta pero la transición apunta al compuesto y no al nodo de historia. Ambos fallos son invisibles leyendo el código y evidentes mirando el dato.

La primera propiedad es que la memoria es local al compuesto: cada nodo de historia tiene su propia entrada, y salir de un compuesto no borra la memoria de otro. La segunda es que la historia sobrevive a la persistencia: si serializas con getPersistedSnapshot y rehidratas con la opción snapshot, el actor restaurado sabe todavía dónde estabas dentro de cada compuesto, lo cual convierte a la historia en la pieza que hace posible reanudar una sesión a través de una recarga completa de página. La tercera es que la memoria es topológica, no de datos: guarda nodos, no contexto.

⚠️
La historia recuerda estado, no lo que había dentro

El error conceptual más frecuente es pedirle a la historia más de lo que ofrece. Restaurar redaccion.preview restaura el modo, no el texto a medio escribir: ese texto vive en el contexto de la máquina, y el contexto tiene su propio ciclo de vida, independiente de la configuración de estados. La separación es saludable y deliberada —la historia gobierna la topología, el contexto gobierna los datos— pero hay que verla para no diseñar contra ella. Si necesitas que al volver reaparezca lo que el usuario había escrito, eso es contexto que nunca debe limpiarse en el exit, y ahí la historia no te ayuda ni te estorba: simplemente no es el mecanismo.

📝
Historia dentro de regiones paralelas

En un estado type: 'parallel', cada región es un compuesto independiente y puede tener su propio nodo de historia. Al salir del paralelo se memorizan todas las regiones a la vez, y al reentrar por historia se restauran todas a la vez, porque la configuración de un paralelo es el producto de sus regiones y la memoria hereda esa estructura. Un nodo de historia profunda colocado en el propio estado paralelo memoriza el corte completo —todas las ramas simultáneas con su interior—, que es justo lo que quieres en un espacio de trabajo con varios paneles abiertos: al volver de una interrupción reaparece la sesión entera, no solo el panel que tenías enfocado.

La historia existe para que la memoria no contamine la lógica

Lo hondo de este mecanismo no es lo que hace, sino lo que evita que tengas que construir. Sin historia, recordar la posición obliga a una de dos salidas, ambas malas. La primera es multiplicar estados espejo: un bloqueado_desde_redaccion, un bloqueado_desde_preview, un bloqueado_desde_terminal, y el conjunto crece como el producto de las configuraciones internas, que es exactamente la explosión combinatoria que los statecharts vinieron a matar. La segunda es cargar el contexto con banderas que anoten la última posición y escribir transiciones condicionales que las lean al volver, lo cual no explota en número de estados pero hace algo peor: entrelaza la memoria de navegación con la lógica del dominio, de modo que ya no puedes leer una transición sin preguntarte si su guardia habla del negocio o de dónde estuvo el usuario hace dos minutos. La historia corta ese nudo declarando que recordar la última posición es una propiedad estructural del estado compuesto, no una responsabilidad de tu código. Es memoria local en el sentido más estricto que existe: pertenece al compuesto, se graba sola al salir, se consulta sola al entrar y no aparece por ninguna parte en las transiciones que expresan qué hace tu sistema. Esa separación entre la topología del estado y la lógica del comportamiento es el sello de un modelo maduro, y cuando la ves con claridad entiendes que un statechart no es una FSM con más sintaxis, sino una máquina con varias clases de memoria bien delimitadas —configuración activa, historia, contexto—, cada una con su alcance, su duración y su propósito. La maestría en este dominio consiste casi entera en poner cada cosa a recordar en el sitio exacto que le toca, y en no dejar que ninguna invada el terreno de las otras.

⚔️ Instrumenta la memoria de un compuesto
  1. Reproduce la máquina editor y comprueba que DESBLOQUEAR apuntando a trabajando a secas siempre aterriza en redaccion.escribiendo, ignorando el nodo de historia aunque exista.
  2. Redirige DESBLOQUEAR al nodo hist con profundidad profunda y verifica que desde preview vuelves a preview.
  3. Cambia la profundidad a superficial y documenta la diferencia exacta observada, incluido el caso terminal, donde ambas coinciden por ser hoja.
  4. Elimina la propiedad history dejando solo type: 'history' y confirma con una traza qué profundidad aplica el motor por defecto.
  5. Imprime snapshot.historyValue antes y después de la primera salida, y explica por qué el target por defecto deja de intervenir a partir de ese momento.
  6. Serializa con getPersistedSnapshot, rehidrata un actor nuevo y demuestra que la memoria de historia sobrevive al viaje.
  7. Argumenta con un ejemplo concreto por qué implementar lo mismo con banderas en el contexto reintroduce acoplamiento entre navegación y dominio.