wandres.dev
JERARQUÍA EN XSTATE · estados compuestos

Transiciones que cruzan niveles: subir, saltar y reentrar

Con profundidad, una transición deja de ser una flecha entre dos puntos y pasa a ser un movimiento entre dos caminos, con un tramo de salida y un tramo de entrada que el intérprete calcula a partir del ancestro común. Esta lección explica cómo se resuelve un objetivo —hermano por nombre, descendiente con punto inicial, cualquier nodo con identificador absoluto—, define el dominio de la transición como la frontera que decide qué acciones de salida y entrada se ejecutan, y desmonta la distinción más sutil y peor entendida de XState v5: interna por defecto, externa solo con `reenter`, y sin objetivo cuando lo único que quieres es actuar sin moverte.

⏱ 19 min

En una máquina plana una transición es trivial: sales de un estado y entras en otro, y solo hay dos acciones de ciclo de vida que ejecutar. Con jerarquía, la misma flecha se convierte en un cálculo. El intérprete debe averiguar hasta dónde subir desde el origen, desde dónde bajar hasta el destino, y qué tramo del árbol permanece intacto porque ambos caminos lo comparten. Ese cálculo tiene un nombre —el dominio de la transición— y de él dependen cosas muy concretas: qué recursos se liberan, qué actores invocados mueren y renacen, qué temporizadores se reinician. Dominar la jerarquía consiste, en gran medida, en saber predecir ese tramo antes de ejecutar nada.

🎯 Al terminar esta lección sabrás
  • Resolver objetivos entre niveles: hermano por nombre, descendiente relativo e identificador absoluto.
  • Calcular el dominio de una transición a partir del ancestro común de origen y destino.
  • Distinguir transición interna, externa con reenter y transición sin objetivo.
  • Predecir qué entry, exit, invoke y after se reinician en cada caso.

Resolver el objetivo entre niveles

XState resuelve un objetivo relativo al padre del estado donde está declarada la transición. Esa regla, sencilla de enunciar, explica los tres tropiezos habituales al cruzar niveles.

pago: {
  initial: 'validando',
  states: {
    validando: {
      on: {
        RECHAZAR: 'rechazado',        // hermano: se resuelve dentro de pago
        DETALLE: '.tresDominios',     // punto inicial: descendiente propio
        CANCELAR: '#compra.carrito',  // identificador absoluto: sale del compuesto
      },
      initial: 'basica',
      states: {
        basica: {},
        tresDominios: {},
      },
    },
    rechazado: {},
  },
  on: { EXPIRAR: 'carrito' },         // declarada en el padre: carrito ES su hermano
}

Antes de leer la explicación, fíjate en que la última línea del bloque está declarada en pago y no en su hoja: por eso puede nombrar a carrito con un nombre desnudo, porque desde el punto de vista de pago sí es un hermano. La misma cadena escrita dentro de validando no resolvería, y ese contraste dentro de un mismo ejemplo es el que conviene retener.

Las tres formas se distinguen por un único carácter y significan cosas muy distintas. Un nombre desnudo busca un hermano del estado que declara la transición. Un nombre precedido de punto busca un descendiente de ese mismo estado. Y una almohadilla introduce un identificador absoluto, que permite apuntar a cualquier nodo del árbol sin importar dónde esté declarada la transición, siempre que ese nodo tenga un id propio o sea alcanzable desde el id de la raíz.

💡
Subir no se hace con el objetivo, se hace con el lugar de declaración

El error más común es intentar escribir una transición hacia arriba desde una hoja profunda usando un nombre que solo existe varios niveles más arriba. No funciona, porque la resolución busca hermanos, no ancestros ni tíos. Hay dos remedios y ambos son legítimos. El primero es declarar la transición en el ancestro adecuado, aprovechando el burbujeo para que la hoja no tenga que conocerla; esta es casi siempre la mejor opción porque mantiene a la hoja ignorante de lo que hay por encima. El segundo es usar un identificador absoluto, útil cuando el salto es realmente excepcional y no representa un comportamiento compartido por los hermanos de la hoja.

El dominio de la transición

Cuando el origen y el destino están en ramas distintas, el intérprete calcula su ancestro común más cercano. Ese ancestro es el dominio de la transición, y funciona como una frontera: todo lo que queda por debajo del dominio en la rama de origen se abandona, todo lo que queda por debajo en la rama de destino se entra, y el dominio mismo y sus ancestros permanecen activos sin ejecutar ni exit ni entry.

flowchart TD
R[raiz compra] --> C[carrito]
R --> P[pago]
P --> V[validando]
P --> X[rechazado]
V --> A[basica]
V --> B[tresDominios]
style P fill:#89b4fa,color:#11111b
style A fill:#f38ba8,color:#11111b
style X fill:#a6e3a1,color:#11111b

Antes de aplicar la regla a un caso conviene enunciarla en la forma que resulta útil recordar: el dominio no depende de dónde esté declarada la transición, sino solo de qué nodos son el origen y el destino. Puedes mover el manejador de un nivel a otro por razones de factorización sin alterar en absoluto qué se sale y qué se entra, siempre que el par de extremos siga siendo el mismo. Lugar de declaración y dominio son dos ejes independientes, y confundirlos produce sorpresas en ambas direcciones.

Toma el salto desde pago.validando.basica hasta pago.rechazado. El ancestro común es pago, así que el dominio es pago. Se ejecutan los exit de basica y de validando, en ese orden de dentro hacia fuera, y después el entry de rechazado. El exit de pago no se ejecuta, y por tanto la pasarela abierta en su entry sigue abierta, sus actores invocados siguen vivos y sus temporizadores siguen corriendo. Si en cambio la transición fuese hasta carrito, el ancestro común sería la raíz, el dominio subiría un nivel más y sí se ejecutaría el exit de pago con toda su limpieza.

🧮

Ancestro común

El dominio es el nodo más profundo que contiene a origen y destino. Todo lo que hay por encima ni sale ni entra.

⬆️

Salida de dentro hacia fuera

Los exit se ejecutan de la hoja hacia el dominio. El hijo se despide antes que el padre, como en una destrucción encadenada.

⬇️

Entrada de fuera hacia dentro

Los entry se ejecutan del dominio hacia la nueva hoja. El padre prepara el terreno antes de que el hijo exista.

♻️

Reinicio de recursos

Cruzar la frontera de un nodo con invoke o after mata y recrea esos recursos. No cruzarla los preserva intactos.

El orden completo de un paso tiene tres tramos y siempre es el mismo, así que puedes predecir cualquier traza sin ejecutarla: primero todos los exit desde la hoja de origen hasta el dominio, después las acciones declaradas en la propia transición, y por último todos los entry desde el dominio hasta la nueva hoja. Instrumentar los tres tramos una vez y comprobar la salida vale más que cualquier explicación.

// Salto de pago.validando.basica a pago.rechazado. Traza esperada:
//   exit basica        <- de dentro
//   exit validando        hacia fuera
//   accion de la transicion
//   entry rechazado    <- del dominio hacia la nueva hoja
// NO aparecen ni exit ni entry de pago: pago es el dominio.

Esta mecánica convierte la elección del nivel donde declaras una transición en una decisión sobre efectos, no solo sobre organización. Poner el manejador de REINTENTAR en el padre o en el hijo no cambia a qué estado llegas, pero puede cambiar si el actor invocado sobrevive o se recrea, y esa diferencia es a menudo el bug que llevas dos horas persiguiendo.

Interna, externa y sin objetivo

XState v5 unificó una de las áreas más confusas de la versión anterior. Hoy la regla es la siguiente: las transiciones son internas por defecto, lo que significa que si el destino es el propio estado o un descendiente suyo, ese estado no se abandona ni se vuelve a entrar. Para forzar el abandono y la reentrada existe reenter: true. Y una transición sin objetivo no mueve nada en absoluto: solo ejecuta sus acciones.

cargando: {
  entry: 'iniciarBarra',
  after: { 5000: 'timeout' },
  invoke: { src: 'descargar' },
  on: {
    // 1. Sin objetivo: actualiza context y NO reinicia after ni invoke.
    PROGRESO: { actions: 'anotarProgreso' },

    // 2. Autotransicion interna: mismo estado, sin exit ni entry.
    LATIDO: { target: 'cargando' },

    // 3. Autotransicion externa: sale y vuelve a entrar.
    //    Se reejecutan entry y exit, y se reinician after e invoke.
    REINTENTAR: { target: 'cargando', reenter: true },
  },
}

Conviene añadir una cuarta forma que no aparece en el bloque porque no se declara en on: la transición sin evento, escrita con always, que el intérprete evalúa después de cada paso y toma en cuanto su guarda se cumple. Cruza niveles con las mismas reglas de resolución y dominio que cualquier otra, pero se dispara sola, y por eso es la herramienta natural para expresar una condición estructural del tipo si ya no queda nada pendiente, sal de aquí, sin obligar a nadie a enviar un evento artificial para provocarlo.

Las tres líneas parecen variaciones triviales y producen comportamientos radicalmente distintos. Un modo cómodo de decidir cuál usar es preguntarse qué debe pasar con lo que ya está en marcha. La primera es la herramienta correcta para acumular datos mientras permaneces en un estado: llega un fragmento de progreso, se anota en el context, y ni el temporizador de cinco segundos ni la descarga en curso se enteran. La segunda cambia de estado formalmente pero, al ser interna y apuntar a sí misma, tampoco reinicia nada. La tercera es la única que reinicia el temporizador y mata y recrea el actor invocado, y es exactamente lo que quieres para un reintento genuino.

⚠️
`reenter` no es una preferencia estilística: reinicia efectos

Añadir o quitar reenter sobre una autotransición altera el ciclo de vida de todo lo que cuelga del estado. Un invoke se detiene y se vuelve a arrancar, con lo que una petición en vuelo se cancela; un after vuelve a contar desde cero, con lo que un tiempo de espera se pospone; los entry se repiten, con lo que cualquier efecto no idempotente ocurre dos veces. Antes de escribirlo, respóndete qué quieres exactamente: si la respuesta es refrescar el proceso, reenter es correcto; si es solo anotar algo sin perturbar lo que ya está en marcha, lo correcto es la transición sin objetivo.

Una transición no es una flecha: es una diferencia entre dos caminos

El salto conceptual que separa a quien usa statecharts de quien los domina es dejar de ver la transición como una arista entre dos nodos y empezar a verla como la diferencia estructural entre dos configuraciones. En una máquina plana ambas visiones coinciden porque el camino tiene longitud uno y la diferencia entre dos caminos es siempre el par completo. Con profundidad se separan, y lo que el intérprete calcula ya no es un movimiento sino un diff: comparar el camino de origen con el de destino, hallar el prefijo compartido, desmontar la cola del primero de dentro hacia fuera y montar la cola del segundo de fuera hacia dentro. Esa operación es idéntica en espíritu a la reconciliación de un árbol de interfaz, donde el subárbol común se preserva y solo cambia lo que difiere; y las consecuencias también son idénticas, porque preservar significa preservar identidad, y con la identidad se preservan los recursos asociados: la conexión abierta, la petición en vuelo, el temporizador a medio contar. De aquí se sigue una regla de diseño que vale para todo el resto de tu vida con máquinas jerárquicas: la profundidad a la que sitúas un invoke, un after o un entry con efectos declara implícitamente cuánto debe vivir ese recurso, porque su vida es exactamente el tiempo durante el cual ese nodo permanece dentro del prefijo compartido de todas las transiciones que ocurran. Colocar un recurso en el padre es afirmar que sobrevive a los movimientos entre hermanos; colocarlo en el hijo es afirmar que muere con cada uno. Y reenter es el operador que te permite decir, en un caso puntual, que aunque el destino no cambie quieres tratarlo como si cambiara, forzando el desmontaje y el montaje de esa cola compartida. Vista así, la jerarquía deja de ser un mapa de dónde estás y se revela como lo que realmente es: un mapa de cuánto duran las cosas.

⚔️ Predice el ciclo de vida antes de ejecutarlo
  1. Instrumenta con trazas los entry y exit de pago, validando y basica, y predice por escrito qué se imprimirá al saltar de basica a rechazado antes de ejecutarlo.
  2. Repite la predicción para un salto de basica a carrito y explica por qué ahora sí aparece el exit de pago.
  3. Intenta apuntar desde validando a carrito con un nombre desnudo, observa el fallo de resolución y arréglalo de las dos formas posibles: declarando la transición en el padre y usando el identificador absoluto.
  4. Coloca un invoke en pago y otro en validando, y comprueba experimentalmente cuál sobrevive a un salto entre hermanos de pago y cuál no.
  5. Escribe las tres variantes de autotransición sobre un estado con after e invoke, y verifica cuál de ellas reinicia el temporizador y cuál cancela la petición en vuelo.
  6. Toma un bug real o inventado de un temporizador que se reinicia solo y explícalo íntegramente en términos del dominio de la transición.