wandres.dev
STATECHARTS · jerarquía y paralelismo

Historia: recordar el subestado al volver

Cuando un statechart abandona un estado compuesto y más tarde regresa, por defecto vuelve a empezar por el subestado inicial y olvida dónde estaba. La historia es el pseudoestado que arregla ese olvido: al reentrar, restaura el último subestado activo en lugar del inicial. Harel distingue dos profundidades: la historia superficial recuerda solo el hijo directo del compuesto, mientras que la historia profunda recuerda la configuración anidada completa, hasta la hoja. Esta lección explica cuándo hace falta cada una, cómo se declaran en XState v5 y por qué la historia es memoria local del estado, no lógica de negocio.

⏱ 15 min

Un statechart bien hecho olvida. Cuando sales de un estado compuesto, toda su configuración interna se descarta; cuando vuelves, arranca limpio por su subestado inicial. Ese olvido es correcto casi siempre —quieres que un diálogo reabierto empiece de cero—, pero a veces es exactamente lo contrario de lo que el usuario espera. Si pausas un reproductor de vídeo para atender una llamada, al volver quieres el mismo fotograma, no el principio. Si un panel de ajustes te lleva a otra pantalla y regresas, quieres la misma pestaña abierta. Harel llamó a este mecanismo la historia: un pseudoestado que, al reentrar en un compuesto, restaura el último subestado que estuvo activo en vez del inicial. Y como los compuestos anidan, hay dos profundidades de memoria que conviene no confundir.

🎯 Al terminar esta lección sabrás
  • Identificar el olvido por defecto al salir y reentrar en un estado compuesto.
  • Distinguir la historia superficial de la profunda por la profundidad que recuerdan.
  • Declarar un pseudoestado de historia en XState v5 y apuntar a él al volver.
  • Reconocer la historia como memoria local del estado, separada de la lógica.

El olvido por defecto

Modela una app con un estado trabajando compuesto, que contiene un editor —a su vez compuesto, con escribiendo y previsualizando— y un terminal. Un evento BLOQUEAR lleva la app a bloqueado, abandonando todo trabajando. Sin historia, DESBLOQUEAR reentra por el subestado inicial: siempre editor en escribiendo, sin importar dónde estuvieras.

import { createMachine } from 'xstate'

const app = createMachine({
  id: 'app',
  initial: 'trabajando',
  states: {
    trabajando: {
      initial: 'editor',
      states: {
        editor: {
          initial: 'escribiendo',
          states: {
            escribiendo: { on: { VISTA_PREVIA: 'previsualizando' } },
            previsualizando: { on: { EDITAR: 'escribiendo' } },
          },
        },
        terminal: { on: { CERRAR: 'editor' } },
      },
      on: { ABRIR_TERMINAL: '.terminal', BLOQUEAR: 'bloqueado' },
    },
    bloqueado: {
      on: { DESBLOQUEAR: 'trabajando' }, // reentra por el inicial: olvida donde estabas
    },
  },
})

Recorre la traza: estás en trabajando.editor.previsualizando, llega BLOQUEAR y pasas a bloqueado. Al DESBLOQUEAR, la máquina reentra en trabajando, toma su inicial editor, y dentro toma el inicial escribiendo. Perdiste la previsualización y también perderías el terminal si hubieras estado en él. El olvido es total.

stateDiagram-v2
[*] --> Trabajando
state Trabajando {
  [*] --> Editor
  state Editor {
    [*] --> Escribiendo
    Escribiendo --> Previsualizando: vista previa
    Previsualizando --> Escribiendo: editar
  }
  Editor --> Terminal: abrir terminal
  Terminal --> Editor: cerrar
}
Trabajando --> Bloqueado: bloquear
Bloqueado --> Trabajando: desbloquear
note right of Bloqueado: sin historia vuelve al inicial y olvida donde estaba

Superficial contra profunda

La historia recuerda, pero surge una pregunta: ¿hasta qué profundidad? Como los compuestos anidan, hay dos respuestas, y elegir mal es un bug sutil.

🪶

Superficial (shallow)

Recuerda solo el hijo directo del compuesto. Si estabas en editor, al volver restaura editor pero deja que este arranque por SU inicial, escribiendo. Recuerda qué región, no dónde dentro de ella.

🪂

Profunda (deep)

Recuerda la configuración anidada entera, hasta la hoja. Si estabas en editor.previsualizando, al volver restaura exactamente editor.previsualizando. Recuerda el camino completo.

La diferencia solo se nota cuando el subestado recordado es a su vez compuesto. Si estabas en trabajando.editor.previsualizando y bloqueas, al desbloquear la historia superficial te devuelve a editor pero en escribiendo —recordó el editor, olvidó la previsualización—, mientras que la profunda te devuelve clavado en previsualizando. Si en cambio estabas en terminal, que es una hoja, ambas se comportan igual: no hay profundidad extra que recordar. La regla práctica: usa superficial cuando solo importa la pestaña o sección, y profunda cuando importa el estado interno completo de esa sección.

📝
Dónde aparece cada profundidad en la práctica

La superficial cubre la navegación por secciones: un panel de ajustes que recuerda la última pestaña abierta, o una app con barra inferior que vuelve a la sección donde estabas. Basta con recordar cuál, no el detalle interno. La profunda cubre la reanudación fiel: un reproductor que vuelve al mismo punto y modo tras una interrupción, o un asistente de varios pasos que retoma exactamente la casilla donde lo dejaste. La pregunta que decide es una sola: cuando el usuario vuelve, ¿le basta con la misma sección, o exige el mismo estado interno completo? La respuesta elige la profundidad.

La historia en XState

En XState v5 la historia es un estado hijo de tipo history, con una propiedad history que vale shallow o deep. Se coloca como un subestado más del compuesto y se apunta a él en la transición de vuelta.

trabajando: {
  initial: 'editor',
  states: {
    editor: {
      initial: 'escribiendo',
      states: {
        escribiendo: { on: { VISTA_PREVIA: 'previsualizando' } },
        previsualizando: { on: { EDITAR: 'escribiendo' } },
      },
    },
    terminal: { on: { CERRAR: 'editor' } },
    hist: { type: 'history', history: 'deep' }, // pseudoestado de historia
  },
  on: { ABRIR_TERMINAL: '.terminal', BLOQUEAR: 'bloqueado' },
},
bloqueado: {
  on: { DESBLOQUEAR: 'trabajando.hist' }, // restaura el ultimo subestado recordado
},

Con history: 'deep', desbloquear desde previsualizando te devuelve a previsualizando. Cambia el valor a shallow y el mismo desbloqueo te dejará en editor.escribiendo: recordó el editor, no su interior. El pseudoestado de historia no es un estado donde la máquina se detenga; es una instrucción de reentrada que se resuelve al instante hacia el subestado memorizado.

💡
La primera vez no hay historia que recordar

Un pseudoestado de historia tiene un problema de arranque: la primera vez que se apunta a él, aún no se ha registrado ningún subestado previo. Para ese caso, un estado de historia admite un target por defecto que indica dónde aterrizar cuando no hay memoria todavía; si no se especifica, se usa el inicial del compuesto. A partir de la primera salida real, la historia ya tiene algo que recordar y el target por defecto deja de intervenir. Es el mismo patrón que un valor por defecto de un parámetro: solo actúa mientras nadie ha proporcionado uno real.

⚠️
La historia recuerda estado, no datos

Un error conceptual frecuente es pedirle a la historia más de lo que ofrece. La historia recuerda en qué subestado estabas, no los datos que había en él. Si el editor tenía texto a medio escribir, ese texto vive en el contexto de la máquina, no en la configuración de estados; restaurar previsualizando no restaura por sí solo el contenido, solo el modo. Separar ambas cosas es justo lo saludable: la historia gobierna la topología —dónde estás en el árbol de estados— y el contexto gobierna los datos. Confundirlas lleva a esperar que la historia haga de persistencia, cosa que no es.

La historia es memoria local, y por eso no ensucia la lógica

La lección honda de la historia es lo que evita tener que construir a mano. Sin este mecanismo, recordar dónde estabas obliga a lo peor: añadir estados espejo —un bloqueado_desde_editor, un bloqueado_desde_terminal, un bloqueado_desde_previsualizando— o a cargar el contexto con banderas que anoten la última posición y transiciones condicionales que las lean al volver. Ambas salidas reintroducen por la puerta de atrás la explosión de estados que todo este nivel se esfuerza en eliminar, y peor aún, entrelazan la memoria de navegación con la lógica del dominio, de modo que ya no puedes razonar sobre una sin la otra. La historia corta ese nudo declarando que recordar la última posición es una propiedad estructural del compuesto, no una responsabilidad de tu código. Es memoria local en el sentido más preciso: pertenece al estado compuesto, se activa sola al salir, se consulta sola al volver, 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 buen modelo. Cuando la ves, entiendes que un statechart no es solo una FSM más expresiva, sino una máquina con distintas clases de memoria —configuración activa, historia, contexto— cada una con su alcance y su propósito, y que la maestría consiste en poner cada cosa a recordar en el lugar exacto que le toca.

⚔️ Recuerda dónde estabas
  1. Implementa la app con trabajando compuesto y DESBLOQUEAR apuntando al inicial; confirma que vuelve siempre a editor.escribiendo.
  2. Añade un pseudoestado hist de tipo profundo y apunta DESBLOQUEAR a él; verifica que desde previsualizando vuelves a previsualizando.
  3. Cambia la profundidad a superficial y comprueba que ahora vuelves a editor pero en escribiendo.
  4. Bloquea estando en terminal y confirma que superficial y profunda se comportan igual porque es una hoja.
  5. Da a la historia un target por defecto y prueba la primera reentrada, cuando aún no hay nada memorizado.
  6. Argumenta por qué implementar la historia con estados espejo o banderas de contexto reintroduce la explosión de estados.