wandres.dev
XSTATE: EL MODELO · createMachine

El objeto de configuración: states, target y estados anidados

La configuración de una máquina es un árbol, no una lista. Esta lección abre ese árbol: cómo una transición se escribe tanto en su atajo de cadena como en su forma de objeto con `target`, cómo un array de transiciones elige destino por guarda, por qué una transición puede no tener `target`, y cómo un estado puede contener sus propios `states` e `initial`. Esa capacidad de anidar es el salto histórico de la máquina de estados plana a la statechart de Harel, y la cura contra la explosión combinatoria de estados.

⏱ 17 min

La lección anterior usó el atajo: on: { ALTERNAR: 'activo' }, un evento que apunta directamente a una cadena. Ese atajo esconde una estructura más rica. Una transición es en realidad un objeto con una propiedad target, y ese objeto es donde más tarde colgarás acciones y guardas. Y los estados, a su vez, no son planos: un estado puede contener otros estados, con su propio initial y su propio states, formando un árbol. Esa jerarquía es lo que separa una máquina de estados finitos clásica de una statechart —la extensión que David Harel propuso en 1987 precisamente para domar la explosión de estados de los sistemas reactivos reales—. Entender el objeto de configuración es entender que la máquina es un árbol de comportamiento, no una tabla de transiciones.

🎯 Al terminar esta lección sabrás
  • Reconocer que el atajo de cadena y la forma de objeto con target describen la misma transición.
  • Saber por qué la forma de objeto admite acciones y guardas, y cómo un array elige destino por condición.
  • Componer estados anidados dando a un estado su propio initial y sus propios states.
  • Leer state.value como objeto cuando la máquina está en un estado compuesto y usar matches sobre la jerarquía.

La transición: atajo de cadena y forma de objeto

Cuando una transición solo necesita indicar a dónde va, basta la cadena. Pero esa cadena es azúcar sintáctico sobre un objeto cuya propiedad target nombra el destino. Ambas formas son idénticas para el motor:

// Atajo de cadena
inactivo: {
  on: { ALTERNAR: 'activo' },
}

// Forma de objeto: exactamente lo mismo
inactivo: {
  on: { ALTERNAR: { target: 'activo' } },
}

La forma de objeto parece verbosa hasta que la transición necesita algo más que un destino. Ahí es donde viven las acciones a ejecutar durante el salto y las guardas que deciden si el salto es legal:

on: {
  ALTERNAR: {
    target: 'activo',
    guard: 'estaPermitido',
    actions: 'registrarCambio',
  },
}
🔤

Atajo de cadena

EVENTO: 'destino'. La forma mínima cuando solo importa a dónde se va. Es azúcar sobre la forma de objeto.

🧱

Forma de objeto

EVENTO: { target, guard, actions }. La forma completa: admite guarda y acciones sin reescribir nada.

🪜

Array de transiciones

EVENTO: [ ... ]. Varios destinos con guardas; gana el primero que pasa. El último sin guarda es el else.

💡
El array de transiciones: primera guarda que pasa

Un mismo evento puede tener varios destinos posibles según una condición. En ese caso on recibe un array de objetos, y el motor prueba las guardas en orden y toma la PRIMERA que se cumple. Es el equivalente declarativo de una cadena if / else if: on: { ENVIAR: [{ target: 'exito', guard: 'esValido' }, { target: 'error' }] }. El último objeto, sin guarda, actúa como el else. Nunca se evalúa más de un destino por evento.

📝
Una transición sin `target` no cambia de estado

Si un objeto de transición omite target, el evento ejecuta sus actions pero la máquina permanece en el mismo estado. Es la forma de reaccionar a un evento —actualizar el contexto, disparar un efecto— sin transicionar. Combinada con guard, permite aceptar o ignorar un evento según una condición sin moverse del sitio. El destino es opcional; declarar el evento manejado, no.

Estados compuestos: una máquina dentro de un estado

La intuición de Harel fue simple y profunda: si un estado puede contener otros estados, gran parte de la explosión combinatoria desaparece. En lugar de multiplicar conectado-inactivo, conectado-sincronizando, desconectado-inactivo como estados planos independientes, declaras un estado conectado que contiene por dentro su propia submáquina. Un estado con hijos se llama compuesto y necesita, igual que la raíz, un initial que diga por cuál de sus hijos empieza.

import { createMachine } from 'xstate'

const cliente = createMachine({
  id: 'cliente',
  initial: 'desconectado',
  states: {
    desconectado: {
      on: { CONECTAR: 'conectado' },
    },
    conectado: {
      initial: 'inactivo',
      states: {
        inactivo: {
          on: { SINCRONIZAR: 'sincronizando' },
        },
        sincronizando: {
          on: { LISTO: 'inactivo' },
        },
      },
      on: { DESCONECTAR: 'desconectado' },
    },
  },
})

Fíjate en la economía: el evento DESCONECTAR se declara UNA vez, en el estado conectado, y aplica a todos sus hijos. En un modelo plano habrías tenido que repetir esa transición en cada subestado. La jerarquía factoriza el comportamiento común hacia arriba, igual que una superclase factoriza métodos compartidos. Esto es la descomposición XOR de Harel: estar en conectado significa estar en exactamente uno de sus hijos, nunca en dos, nunca en ninguno.

Un detalle con consecuencias: cuando la máquina entra en un estado compuesto, no se posa de forma abstracta en conectado, sino que desciende automáticamente hasta su initial y aterriza en inactivo. Entrar en un compuesto es siempre entrar en su estado inicial; por eso todo compuesto debe declarar uno, igual que la raíz. La transición hacia conectado es, en rigor, una transición a conectado seguida de un descenso a su hijo por defecto, y ese descenso es automático y obligatorio.

stateDiagram-v2
[*] --> desconectado
desconectado --> conectado: CONECTAR
conectado --> desconectado: DESCONECTAR
state conectado {
  [*] --> inactivo
  inactivo --> sincronizando: SINCRONIZAR
  sincronizando --> inactivo: LISTO
}

state.value y matches: leer la jerarquía

Cuando la máquina está en un estado atómico, state.value es una cadena: 'desconectado'. Pero cuando está en un estado compuesto, state.value es un objeto que refleja el camino en el árbol: { conectado: 'sincronizando' }. Ese objeto no es un accidente de implementación; es la representación fiel de que el estado activo es una ruta, no un punto.

const snapshot = actor.getSnapshot()
snapshot.value                          // { conectado: 'sincronizando' }
snapshot.matches('conectado')           // true para cualquier subestado
snapshot.matches({ conectado: 'sincronizando' })  // true solo en ese
snapshot.matches('desconectado')        // false

matches es la forma correcta de preguntar por el estado sin acoplarte a la profundidad exacta. Preguntar matches('conectado') responde “sí” tanto si estás en inactivo como en sincronizando, porque ambos viven bajo conectado. Comparar state.value con igualdad estricta contra un objeto es frágil: si mañana añades un tercer subestado, tu comparación exacta se rompe, pero matches('conectado') sigue siendo verdadera. Por eso matches entiende la jerarquía y es la interfaz pública; la forma cruda de state.value es para inspección, no para lógica de negocio.

ℹ️
Estados paralelos: cuando la jerarquía no basta

La descomposición XOR dice que estás en uno de los hijos. Harel añadió también la descomposición AND: un estado type: 'parallel' está en TODOS sus hijos a la vez, cada uno con su propia región independiente. Es la herramienta para modelar dimensiones ortogonales —por ejemplo, el estado de un editor que es a la vez guardado o modificado y en foco o sin foco— sin multiplicar los cuatro casos en cuatro estados planos. Es material de niveles posteriores, pero conviene saber que la anidación tiene esta segunda cara, y que state.value de un estado paralelo es un objeto con una clave por región.

📝
Saltar por toda la jerarquía con un `id`

Dentro de un árbol de estados, una transición a veces necesita saltar a un estado que no es hermano ni hijo, sino que vive en otra rama. Para eso están los identificadores absolutos: si das a un estado un id propio, cualquier transición puede apuntarlo como #suId, con independencia de dónde esté en el árbol. Es la vía de escape a la resolución relativa por defecto, y conviene reservarla para los pocos saltos que de verdad cruzan ramas, porque cada uno es una arista que el diagrama tendrá que dibujar de un extremo a otro.

El árbol de estados es la cura de la explosión combinatoria

La razón por la que las máquinas de estados planas cayeron en desuso en los años ochenta fue práctica: un sistema reactivo real tiene tantas situaciones que enumerarlas todas produce cientos de estados y miles de transiciones repetidas. La statechart de Harel no añadió poder expresivo —una máquina jerárquica se puede aplanar a una plana equivalente— sino poder de ABSTRACCIÓN. Anidar permite decir “esta transición aplica a todo un subárbol” una sola vez, y eso convierte una tabla ingobernable en un árbol legible. Cuando escribes initial y states dentro de un estado, no estás usando una comodidad sintáctica: estás factorizando el comportamiento común de una familia de situaciones hacia su ancestro, exactamente como la herencia factoriza código. Y cuando lees state.value como el objeto { conectado: 'sincronizando' }, estás leyendo una ruta en ese árbol, no una etiqueta. Interiorizar esto cambia cómo modelas: dejas de preguntarte “cuántos estados necesito” y empiezas a preguntarte “cuál es la jerarquía de situaciones”, que es una pregunta de diseño mucho más productiva. La máquina plana era una tabla; la statechart es un árbol, y el árbol es lo que escala a los sistemas que de verdad construyes. La misma idea que hace legible un componente —composición y anidamiento— es la que hace gobernable una máquina de mil estados.

⚔️ Anida una máquina de reproductor
  1. Modela un reproductor con dos estados de nivel superior: detenido y reproduciendo.
  2. Convierte reproduciendo en un estado compuesto con subestados normal y silenciado, y un initial en normal.
  3. Declara el evento PARAR una sola vez en reproduciendo de modo que aplique a ambos subestados.
  4. Reescribe una de tus transiciones del atajo de cadena a la forma de objeto con target y añade una actions de nombre avisar; luego crea una transición sin target que solo ejecute una acción.
  5. Escribe qué devuelve state.value estando en silencio y verifica que matches('reproduciendo') y matches({ reproduciendo: 'silenciado' }) dan ambos true.