wandres.dev
STATECHARTS · jerarquía y paralelismo

Estados jerárquicos: un estado que contiene una máquina

La primera adición de Harel es la profundidad: un estado compuesto no es un punto, sino que aloja dentro una submáquina completa con su propio estado inicial y sus transiciones. La consecuencia operativa es el burbujeo: cuando llega un evento, lo resuelve el subestado más interno que lo maneje, y si ninguno lo hace, sube por la jerarquía hasta encontrar quien lo atienda. Esta lección disecciona qué es un estado compuesto, cómo se entra y se sale de él, y por qué la resolución de eventos que sube por el árbol elimina de un plumazo la explosión aditiva de aristas.

⏱ 16 min

En una FSM plana un estado es un punto sin interior. La jerarquía de Harel rompe esa atomicidad: un estado compuesto contiene otros estados y se comporta como una máquina anidada dentro de otra. Cuando el sistema entra en el compuesto, arranca automáticamente por su subestado inicial; mientras permanece dentro, está a la vez en el compuesto y en uno de sus hijos. Esta doble pertenencia es la clave de todo. Un evento no se resuelve en un único lugar, sino que se ofrece primero al subestado más interno y, si este no sabe qué hacer con él, sube por la jerarquía buscando un ancestro que lo atienda. Ese burbujeo es la mecánica que permite declarar una vez lo que vale para muchos, y con ella la explosión aditiva de aristas simplemente desaparece.

🎯 Al terminar esta lección sabrás
  • Definir un estado compuesto como una submáquina con estado inicial propio.
  • Entender la configuración activa como un camino de la raíz a una hoja.
  • Explicar el burbujeo: la resolución de un evento sube por la jerarquía.
  • Manejar la entrada, la salida y el targeting relativo entre niveles.

Un estado con interior

Un estado compuesto se declara igual que la máquina raíz: con un initial y un mapa de states. La diferencia es que vive dentro de otro estado. Modelemos un reproductor cuyo estado encendido contiene una submáquina de reproducción.

import { createMachine } from 'xstate'

const reproductor = createMachine({
  id: 'reproductor',
  initial: 'encendido',
  states: {
    encendido: {
      initial: 'pausado', // al entrar en encendido, se arranca aqui
      states: {
        pausado: { on: { PLAY: 'reproduciendo' } },
        reproduciendo: { on: { PAUSE: 'pausado' } },
      },
      on: { APAGAR: 'apagado' }, // declarado UNA vez: cubre todos los subestados
    },
    apagado: { on: { ENCENDER: 'encendido' } },
  },
})

Mientras el reproductor está encendido, no está solo en encendido: está en encendido y, dentro, en pausado o en reproduciendo. La configuración activa es el camino completo desde la raíz hasta la hoja, y en XState se refleja como un valor anidado. Estar reproduciendo se expresa como el objeto que empareja el compuesto con su hijo activo, no como una cadena suelta.

const actor = createActor(reproductor).start()
actor.send({ type: 'PLAY' })
actor.getSnapshot().value // { encendido: 'reproduciendo' }
stateDiagram-v2
[*] --> Encendido
state Encendido {
  [*] --> Pausado
  Pausado --> Reproduciendo: play
  Reproduciendo --> Pausado: pause
}
Encendido --> Apagado: apagar
Apagado --> Encendido: encender

El burbujeo: el evento sube por la jerarquía

Aquí está el corazón de la lección. Cuando llega un evento, el intérprete no lo entrega a un lugar fijo, sino que lo ofrece de dentro hacia afuera. El algoritmo es exactamente este:

  1. Se parte del estado hoja activo, el más interno.
  2. Si la hoja declara una transición para ese evento, la toma y termina.
  3. Si no, el evento sube al estado padre y se repite la pregunta.
  4. Se asciende así hasta la raíz; el primer ancestro que lo maneje gana.
  5. Si nadie lo maneja, el evento se descarta sin efecto.

Con el reproductor encendido y reproduciendo, un APAGAR no lo maneja reproduciendo, así que sube a encendido, que sí lo declara: la máquina apaga. Un PAUSE, en cambio, lo resuelve la propia hoja reproduciendo sin subir. Esta resolución de dentro hacia afuera es la razón exacta por la que una única transición APAGAR en el superestado vale para todos sus hijos: los hijos no la manejan, así que el evento burbujea hasta donde está declarada.

💡
El burbujeo es herencia de comportamiento

Piensa en el subestado como una subclase y en el superestado como su clase base. Un subestado hereda todas las transiciones de sus ancestros sin escribirlas, igual que una subclase hereda métodos. Y puede redefinir una: si reproduciendo declarara su propio APAGAR, ganaría al del ancestro, porque el más interno tiene prioridad. Es la misma regla de resolución que el override de métodos, aplicada a transiciones. Declarar arriba lo común y redefinir abajo lo particular es lo que convierte la jerarquía en una herramienta de factorización, no de mera agrupación visual.

Un subestado que quiere interceptar un evento del ancestro solo tiene que declararlo. El más interno gana siempre.

reproduciendo: {
  on: {
    PAUSE: 'pausado',
    APAGAR: 'guardando', // redefine APAGAR SOLO para este subestado
  },
},

La jerarquía, entonces, no es un adorno del diagrama: es un mecanismo de factorización con reglas precisas de herencia y resolución. Conviene fijar las cuatro que hacen todo el trabajo.

⬆️

Factoriza hacia arriba

Lo que comparten muchos subestados se declara una vez en el ancestro. Una sola transición APAGAR en encendido cubre pausado y reproduciendo, y cualquier subestado futuro la heredará gratis.

⬇️

Redefine hacia abajo

Un subestado puede declarar su propia versión de un evento del ancestro y ganarle, porque el más interno tiene prioridad. Es override de comportamiento, no duplicación.

🎬

Ciclo de vida por nivel

Entrar en un compuesto entra también en su hijo inicial, de fuera hacia dentro; salir de él abandona a todos sus descendientes, de dentro hacia afuera. Cada nivel tiene su propio entrar y salir.

🧵

Configuración como camino

Estar en un compuesto es estar a la vez en él y en una de sus hojas. La posición del sistema es un camino de la raíz al fondo, no un punto suelto.

Entrar, salir y apuntar entre niveles

Entrar en un compuesto sin más detalle activa su subestado inicial: es la regla por defecto. Pero una transición puede apuntar a un subestado concreto saltándose el inicial, usando un objetivo cualificado que nombra el camino.

apagado: {
  on: { ENCENDER: 'encendido.reproduciendo' }, // entra directo al subestado
},

Salir de un compuesto tiene una consecuencia importante: al abandonar el superestado se abandonan también todos sus descendientes activos, de dentro hacia afuera. Esto importará mucho cuando veamos las acciones de salida, porque cada nivel que se abandona ejecuta su propia limpieza. Para apuntar desde un subestado a un hermano de un ancestro, XState resuelve el objetivo trepando por el árbol hasta encontrar un estado con ese nombre; también existen objetivos relativos con notación de punto para bajar dentro del mismo nivel. La regla mental es simple: una transición nunca cruza a otra máquina, solo se mueve dentro del árbol en el que vive.

Hay un matiz que enlaza con la siguiente lección. Apuntar a un compuesto activa su subestado inicial, pero eso significa que reentrar en él siempre lo reinicia: se pierde en qué subestado estabas la última vez. A menudo es justo lo que quieres, pero no siempre; recordar la posición previa al volver es precisamente lo que resolverá la historia. Por ahora basta con retener que, sin más mecanismo, entrar en un compuesto es empezar de cero por su inicial.

⚠️
Un compuesto necesita siempre un estado inicial

Olvidar el initial de un estado compuesto es el error más común al empezar. Sin él, el intérprete no sabe en qué subestado aterrizar al entrar y la máquina queda mal formada. Un estado compuesto sin initial es como una función que promete devolver algo y no tiene return: la definición está incompleta. XState v5 lo detecta al construir la máquina. La única excepción son los estados atómicos —hojas sin hijos—, que por no tener interior no necesitan inicial.

La jerarquía convierte estados en ámbitos

El giro conceptual de la profundidad es que un estado deja de ser un valor y pasa a ser un ámbito. En una FSM plana todas las transiciones viven en el mismo espacio global y la única manera de compartir comportamiento es duplicarlo; por eso AVERIA aparecía en cada estado del semáforo. La jerarquía introduce anidamiento léxico: lo declarado en un superestado está en el alcance de todos sus descendientes, exactamente como una variable de un ámbito exterior es visible desde los interiores. El burbujeo no es entonces un detalle de implementación, sino la regla de resolución de nombres de ese sistema de ámbitos: cuando llega un evento, se busca su manejador de dentro hacia afuera, y el más cercano tapa a los más lejanos, igual que una variable local tapa a una global. Esta correspondencia es profunda y práctica a la vez. Significa que puedes razonar sobre un statechart con la misma intuición con la que razonas sobre alcance de variables y herencia de métodos: factoriza hacia arriba lo que comparten muchos, especializa hacia abajo lo que distingue a pocos, y confía en que la resolución más interna gana. La explosión aditiva de aristas era, vista así, el síntoma de un lenguaje sin ámbitos; la jerarquía es el ámbito que le faltaba.

⚔️ Domina la profundidad
  1. Construye el reproductor con encendido compuesto y comprueba que getSnapshot().value devuelve un valor anidado al reproducir.
  2. Envía APAGAR desde pausado y desde reproduciendo y verifica que ambos apagan gracias a la única arista del superestado.
  3. Añade a reproduciendo una transición propia de APAGAR hacia un estado guardando y observa cómo el subestado gana al ancestro.
  4. Escribe una transición que entre directamente en encendido.reproduciendo saltándose el subestado inicial.
  5. Traza a mano el algoritmo de burbujeo para un evento no manejado por ningún estado y confirma que se descarta sin efecto.
  6. Explica la analogía entre el burbujeo y la resolución de variables por ámbito léxico con un ejemplo propio.