wandres.dev
JERARQUÍA EN XSTATE · estados compuestos

Estados compuestos: anidar `states` dentro de un estado

El nivel 2 presentó la jerarquía como idea de Harel; este nivel la convierte en oficio dentro de XState v5. Un estado compuesto no es una etiqueta con hijos decorativos: es un nodo que aloja una máquina entera, con su propio `initial`, su propio mapa de `states` y su propio ciclo de entrada y salida. La consecuencia que reorganiza todo lo demás es que el estado activo deja de ser un nombre suelto y pasa a ser una ruta desde la raíz hasta una hoja, materializada como valor anidado en el snapshot. Esta lección disecciona la anatomía del nodo compuesto, el contrato inviolable del `initial`, el orden en que se ejecutan las acciones de entrada y salida nivel a nivel, y por qué comparar el estado con una cadena deja de funcionar en cuanto aparece profundidad.

⏱ 18 min

Cuando estudiaste los statecharts de Harel aceptaste que un estado podía tener interior. Ahora toca cobrar esa promesa en código, y descubrir que la jerarquía no es una comodidad de diagrama sino un cambio en el tipo del estado. En una máquina plana, preguntar dónde estamos devuelve una cadena; en una máquina jerárquica devuelve un árbol, porque el sistema está a la vez en el padre y en el hijo, y en el hijo del hijo. La configuración activa es una ruta completa desde la raíz hasta una hoja, no una posición atómica. Todo lo que viene después en este nivel —comprobar estados, abrir regiones paralelas, cruzar niveles con una transición— depende de que interiorices esta primera mudanza: el estado dejó de ser un punto y se volvió un camino.

🎯 Al terminar esta lección sabrás
  • Declarar un nodo compuesto con su propio initial y su propio mapa de states.
  • Leer el valor del snapshot como una ruta anidada de la raíz a la hoja.
  • Ordenar la ejecución de entry y exit nivel a nivel al entrar y salir del compuesto.
  • Reconocer los cuatro tipos de nodo y el contrato que cada uno debe cumplir.

Un nodo que aloja su propia máquina

Anidar en XState es sintácticamente trivial y semánticamente denso: dentro de un estado escribes de nuevo las dos claves que definen cualquier máquina, initial y states. A partir de ahí ese nodo se comporta como una máquina completa alojada dentro de otra. Modelemos una compra en la que el pago no es un instante sino un proceso con vida propia.

import { setup, createActor } from 'xstate'

const compra = setup({
  types: {} as {
    context: { intentos: number }
    events:
      | { type: 'PAGAR' }
      | { type: 'AUTORIZAR' }
      | { type: 'RECHAZAR' }
      | { type: 'CANCELAR' }
  },
}).createMachine({
  id: 'compra',
  initial: 'carrito',
  context: { intentos: 0 },
  states: {
    carrito: { on: { PAGAR: 'pago' } },
    pago: {
      initial: 'validando',           // contrato del nodo compuesto
      entry: 'abrirPasarela',
      exit: 'cerrarPasarela',
      states: {
        validando: { on: { AUTORIZAR: 'autorizado', RECHAZAR: 'rechazado' } },
        rechazado: { on: { PAGAR: 'validando' } },
        autorizado: { type: 'final' },
      },
      on: { CANCELAR: 'carrito' },    // declarado UNA vez para los tres hijos
      onDone: 'confirmada',
    },
    confirmada: { type: 'final' },
  },
})

El nodo pago hace tres cosas que un estado plano no puede hacer. Aloja una submáquina con tres hijos y un inicial declarado. Declara CANCELAR una sola vez y ese manejador cubre a los tres subestados por herencia, sin repetir aristas. Y define un ciclo de vida propio con entry y exit que envuelve toda la estancia dentro del compuesto, sin importar por cuántos hijos se pasee el usuario mientras tanto.

💡
El compuesto es una unidad de recursos, no solo de agrupación

La pareja entry y exit del nodo padre es el motivo más práctico para anidar. Abrir la pasarela de pago al entrar en pago y cerrarla al salir es correcto sin importar si el usuario recorrió validando, cayó en rechazado y volvió a intentarlo: mientras siga dentro del compuesto, el recurso permanece abierto porque no se ha ejecutado ningún exit del padre. Moverse entre hermanos no atraviesa la frontera del padre. Esto convierte al estado compuesto en la unidad natural para adquirir y liberar recursos con ámbito, exactamente como un bloque léxico en un lenguaje con destructores.

El valor activo es una ruta

Con profundidad, el snapshot deja de devolver una cadena. Devuelve un objeto que codifica el camino, y esa diferencia rompe cualquier comparación ingenua que hubieras escrito para máquinas planas.

const actor = createActor(compra).start()

actor.getSnapshot().value            // 'carrito'   -> hoja en la raiz: cadena
actor.send({ type: 'PAGAR' })
actor.getSnapshot().value            // { pago: 'validando' }  -> ruta anidada
actor.send({ type: 'RECHAZAR' })
actor.getSnapshot().value            // { pago: 'rechazado' }

// La comparacion por igualdad deja de servir en cuanto hay profundidad:
actor.getSnapshot().value === 'pago' // false SIEMPRE, aunque estemos en pago

La última línea es el tropiezo canónico del recién llegado a la jerarquía. Estar en pago es cierto, pero el valor nunca será la cadena pago, porque pago no es una hoja: es un tramo del camino. Un nodo compuesto jamás aparece solo en el valor; aparece como clave de un objeto cuyo valor es su hijo activo. Para preguntar por tramos intermedios existe state.matches, y esa es exactamente la lección siguiente.

stateDiagram-v2
[*] --> Carrito
state Pago {
  [*] --> Validando
  Validando --> Rechazado: rechazar
  Rechazado --> Validando: pagar
  Validando --> Autorizado: autorizar
}
Carrito --> Pago: pagar
Pago --> Carrito: cancelar
Pago --> Confirmada: onDone

El diagrama hace visible lo que el valor codifica: la caja Pago no es un nodo del mismo rango que Carrito, es una frontera que contiene otro plano. Cuando el sistema está dentro de esa caja ocupa dos posiciones a la vez, la exterior y la interior, y el valor anidado es la transcripción literal de esa doble pertenencia.

Conviene además separar dos cosas que se confunden con facilidad: el valor y la identidad del nodo. El valor es el camino activo y cambia en cada transición; la identidad de un nodo es su ruta en la definición, fija desde que escribiste la máquina. XState expone la segunda a través de la propiedad id y de las rutas cualificadas, y es lo que permite apuntar a un nodo profundo desde cualquier lugar del árbol. Retén la distinción porque la lección cuarta la necesitará entera.

// La definicion tiene rutas fijas; el snapshot tiene un camino que varia.
compra.states.pago.states.rechazado.id   // 'compra.pago.rechazado' (definicion)
actor.getSnapshot().value                // { pago: 'rechazado' }   (ejecucion)

Los cuatro tipos de nodo y sus contratos

XState clasifica cada nodo por lo que tiene dentro, y cada clase impone un contrato distinto que el compilador de la máquina verifica al construirla.

⚛️

Atómico

Sin hijos. Es una hoja y aparece tal cual en el valor. No admite initial porque no hay nada que inicializar dentro.

📦

Compuesto

Tiene states y exige initial. Al entrar activa a su hijo inicial; al salir abandona a todos sus descendientes. Nunca aparece solo en el valor.

🔀

Paralelo

Lleva type: parallel y prohíbe initial: activa todas sus regiones a la vez. Su valor es un objeto con una clave por región.

🏁

Final

Lleva type: final. Cuando todos los hijos requeridos de un compuesto llegan a final, el padre emite onDone hacia arriba.

El nodo final merece un comentario aparte porque es el único cuyo efecto se propaga hacia arriba. Cuando pago.autorizado se marca como final, el compuesto pago se considera terminado y dispara su propio onDone, que el padre puede usar como cualquier otro manejador. Es el mecanismo con el que un subproceso informa de su desenlace sin que el padre tenga que espiar sus subestados, y es la razón de que la jerarquía permita encapsular de verdad y no solo agrupar.

// El hijo llega a final -> el compuesto emite onDone -> el padre transita.
actor.send({ type: 'PAGAR' })
actor.send({ type: 'AUTORIZAR' })
actor.getSnapshot().value    // 'confirmada', no { pago: 'autorizado' }

La regla de entrada y salida es simétrica y merece memorizarse porque gobierna todo el orden de efectos. Al entrar se recorre de fuera hacia dentro: primero el entry del padre, después el entry del hijo inicial. Al salir se recorre de dentro hacia fuera: primero el exit del hijo activo, después el exit del padre. Es el mismo orden que la construcción y destrucción de un objeto con herencia, y por la misma razón: el padre establece el contexto en el que el hijo puede existir, así que el hijo no puede sobrevivir a su padre ni preceder a su creación.

⚠️
Un compuesto sin `initial` es una máquina mal formada

Declarar states y olvidar initial es el error más frecuente al anidar, y XState v5 lo rechaza al construir la máquina, no en tiempo de ejecución. La razón es que el nodo prometió tener interior pero no dijo dónde aterrizar al entrar, así que el intérprete no puede calcular ninguna configuración inicial válida. La excepción es el nodo paralelo, donde initial no solo sobra sino que está prohibido: activar todas las regiones a la vez es precisamente no elegir ninguna.

La jerarquía cambia el tipo del estado, no su decoración

Es tentador leer el anidamiento como una mejora ergonómica: cajas dentro de cajas para que el diagrama respire y las aristas comunes no se repitan. Esa lectura es cierta y es superficial. Lo que ocurre de verdad al anidar es un cambio de tipo: el estado pasa de ser un elemento de un conjunto finito a ser un camino en un árbol, y con él cambian todas las operaciones que puedes hacer sobre él. La igualdad deja de ser la comparación correcta y la sustituye la pertenencia a un prefijo, que es lo que implementa state.matches. El manejo de un evento deja de ser una búsqueda en una tabla y pasa a ser una resolución por ámbitos que asciende por el camino hasta encontrar el primer manejador, exactamente como la resolución léxica de un identificador. Los efectos dejan de ser puntuales y se vuelven jerárquicos, con adquisición al entrar y liberación al salir, ordenados por profundidad como los constructores y destructores de una cadena de herencia. Y la exhaustividad, esa virtud que te vendieron con las máquinas planas, se vuelve exhaustividad por nivel: cada compuesto es exhaustivo sobre sus hijos, y el sistema es exhaustivo sobre la composición de esos niveles. Reconocer que el estado es ahora un dato estructurado y no un escalar es lo que te permite predecir el comportamiento de todo lo que sigue: por qué comparar con una cadena falla, por qué una transición al padre reinicia al hijo, por qué un evento no manejado abajo puede resolverse arriba. Quien sigue pensando en nombres sueltos con cajas alrededor tropezará una y otra vez con esas tres cosas; quien piensa en caminos las deduce sin necesidad de memorizarlas.

⚔️ Convierte un flujo plano en uno compuesto
  1. Escribe la máquina compra y comprueba con getSnapshot().value que el valor es una cadena en carrito y un objeto anidado dentro de pago.
  2. Verifica que la comparación por igualdad con la cadena pago devuelve falso incluso estando dentro, y explica por qué el valor nunca contiene un compuesto en solitario.
  3. Instrumenta entry y exit del padre y de cada hijo con trazas, y confirma el orden de fuera hacia dentro al entrar y de dentro hacia fuera al salir.
  4. Envía RECHAZAR y luego PAGAR varias veces y comprueba que el exit del padre no se ejecuta mientras te muevas entre hermanos del mismo compuesto.
  5. Borra el initial de pago y observa que XState rechaza la máquina al construirla; razona qué configuración inicial no podría calcular el intérprete.
  6. Toma un flujo plano tuyo con un prefijo repetido en varios nombres de estado y refactorízalo a un compuesto, midiendo cuántas aristas duplicadas desaparecen.