Invocar máquinas: composición jerárquica de statecharts
Una máquina de estado es, ella misma, una lógica de actor. De esa igualdad nace la propiedad más potente de XState: una máquina puede invocar a otra igual que invocaría una promesa, y así los statecharts se componen en jerarquías de padres e hijos. El padre delega un subproblema entero a una máquina hija, le pasa un input, escucha su salida por onDone y dialoga con ella mediante eventos. Es el mismo principio que descompone un sistema grande en servicios pequeños, trasladado al interior de la gestión de estado: máquinas que orquestan máquinas.
Hasta aquí un actor invocado envolvía una promesa, un flujo o un callback: cosas que no eran, en sí mismas, máquinas de estado. Pero XState v5 unifica el modelo hasta sus últimas consecuencias con una igualdad de una sola línea: una máquina es una lógica de actor. Esto significa que todo lo que sabes hacer con fromPromise lo puedes hacer con una máquina entera —invocarla, pasarle input, escuchar su onDone— porque a ojos del padre no hay diferencia entre invocar una promesa y invocar un statechart completo. La consecuencia es la composición jerárquica: sistemas grandes descritos como máquinas que invocan máquinas, cada una responsable de un subproblema acotado, comunicándose por un protocolo explícito de eventos y salidas. Es la misma disciplina que llevó a la arquitectura de microservicios, pero aplicada dentro de tu aplicación, y sin ninguna de las penalidades de la red.
- Comprender que una máquina de estado es una lógica de actor invocable.
- Definir la salida de una máquina hija con
outputy recibirla en el padre poronDone. - Comunicar padre e hijo mediante
sendTo,sendParentyreceive. - Escuchar el estado interno del hijo con
onSnapshotsin romper su encapsulación.
Una máquina es un actor
La clave conceptual es que createMachine no produce algo esencialmente distinto de lo que produce fromPromise: ambos devuelven una lógica de actor, un valor que createActor puede animar y que invoke puede hospedar. Por eso puedes registrar una máquina hija en setup.actors y referenciarla desde src exactamente igual que cualquier otra lógica.
import { setup } from 'xstate'
import { maquinaPago } from './pago'
const maquinaCheckout = setup({
actors: { pago: maquinaPago },
}).createMachine({
id: 'checkout',
initial: 'pagando',
states: {
pagando: {
invoke: {
id: 'pago',
src: 'pago',
input: ({ context }) => ({ total: context.total, moneda: context.moneda }),
onDone: { target: 'confirmado', actions: 'guardarRecibo' },
onError: 'rechazado',
},
},
confirmado: { type: 'final' },
rechazado: {},
},
})
Desde checkout, la máquina de pago es una caja negra: entra un input, sale un output, y en medio ocurre toda una vida de estados —validando, autorizando, capturando— que al padre no le incumbe. Esa opacidad es exactamente lo que hace escalable la composición: el padre razona sobre el contrato, no sobre las tripas del hijo.
output: cómo una máquina devuelve un resultado
Una promesa resuelve con un valor; una máquina, para “resolver”, debe alcanzar un estado final de su nivel raíz. En ese momento evalúa su campo output, y ese valor es el que viaja al padre como event.output de la transición onDone. Definir output es, literalmente, escribir el return de la máquina vista como función.
import { setup, assign } from 'xstate'
export const maquinaPago = setup({
types: {
input: {} as { total: number; moneda: string },
context: {} as { total: number; moneda: string; idTransaccion: string | null },
},
}).createMachine({
id: 'pago',
context: ({ input }) => ({ ...input, idTransaccion: null }),
initial: 'autorizando',
states: {
autorizando: {
on: {
AUTORIZADO: {
target: 'capturado',
actions: assign({ idTransaccion: ({ event }) => event.id }),
},
DENEGADO: 'fallido',
},
},
capturado: { type: 'final' },
fallido: { type: 'final' },
},
output: ({ context }) => ({ idTransaccion: context.idTransaccion }),
})
Observa cómo el context inicial del hijo se construye a partir de su input: es el patrón canónico para sembrar una máquina hija con los datos que el padre le entrega. Y nota que output se evalúa al llegar a cualquier final de nivel raíz, sea el de éxito o el de fallo; si necesitas distinguirlos, codifícalo en el propio output o usa estados finales con datos distintos.
Una máquina hija no se detiene porque el padre lo decida, sino porque ella misma llega a un estado final de su nivel superior. Ese es el instante en que produce su output y dispara el onDone del padre. Diseñar bien los estados finales del hijo es diseñar su contrato de salida.
Comunicación bidireccional: sendTo, sendParent y onSnapshot
La invocación no es solo entrada y salida: mientras ambos viven, padre e hijo pueden hablar. El padre dirige eventos al hijo con sendTo, apuntando al id del actor invocado. El hijo devuelve eventos al padre con la acción sendParent. Y si el padre quiere observar el estado interno del hijo sin esperar a que termine, declara onSnapshot, que se dispara con cada cambio de instantánea del hijo.
import { sendTo, assign } from 'xstate'
pagando: {
invoke: {
id: 'pago',
src: 'pago',
input: ({ context }) => ({ total: context.total, moneda: context.moneda }),
onSnapshot: {
actions: assign({
estadoPago: ({ event }) => event.snapshot.value,
}),
},
onDone: 'confirmado',
},
on: {
CANCELAR_PAGO: { actions: sendTo('pago', { type: 'CANCELAR' }) },
},
}
flowchart TD P[padre checkout] -->|input y sendTo| H[hijo pago] H -->|output en onDone| P H -->|sendParent y onSnapshot| P subgraph hijo H --> A[autorizando] A --> C[capturado final] A --> F[fallido final] end style P fill:#cba6f7,color:#11111b style H fill:#89b4fa,color:#11111b
onSnapshot te deja reflejar el progreso del hijo en la UI del padre —una barra que avanza con sus subestados— sin violar su encapsulación: solo lees su instantánea, no manipulas su interior. Si te ves tentado de reaccionar a cada subestado del hijo con lógica compleja, quizá la frontera entre ambas máquinas esté mal trazada.
Trazar bien la frontera padre-hijo
Componer máquinas no es gratis: cada frontera es un contrato que hay que diseñar. Una buena descomposición asigna al hijo un subproblema con un principio y un final nítidos —un asistente de varios pasos, un flujo de pago, una subida de archivo— cuyo resultado el padre necesita pero cuyo detalle interno no. Una mala descomposición parte una lógica cohesionada en dos máquinas que no paran de mandarse eventos para coordinarse: síntoma de que ambas eran, en realidad, una sola.
Lo que convierte a XState en una herramienta de arquitectura y no solo de gestión de estado es esta igualdad recursiva: una máquina es una lógica de actor, y una lógica de actor es lo que una máquina invoca. Esa simetría es la misma que sostiene toda buena descomposición de sistemas. Un microservicio es un servidor para quien lo llama y un cliente para los servicios que él llama; un componente de UI es un contenedor para sus hijos y un hijo para su contenedor. Los statecharts jerárquicos trasladan ese principio fractal al corazón del estado: cada máquina es, a la vez, un servicio que expone un contrato —recibe input, emite output, acepta ciertos eventos— y un cliente que consume los contratos de las máquinas que invoca. La potencia de esto es que la complejidad deja de crecer de forma monolítica. En vez de una máquina gigante con cincuenta estados imposible de razonar, tienes un árbol de máquinas pequeñas, cada una comprensible en aislamiento y verificable por su contrato, compuestas por invocación. Y como toda la comunicación es explícita —input, output, eventos con nombre— la frontera entre dos máquinas es una interfaz auditable, no un enredo de variables compartidas. Cuando dominas esto dejas de preguntarte “¿cómo modelo este flujo tan grande?” y empiezas a preguntar “¿cuáles son sus subproblemas y qué contrato tiene cada uno?”. Esa es la diferencia entre programar estados y arquitecturar sistemas reactivos: el actor invocado te da el mecanismo, pero la igualdad máquina-es-actor te da la disciplina de composición que escala a cualquier tamaño.
- Extrae un flujo de pago a su propia máquina con estados
autorizando,capturadoyfallido, y dale unoutput. - Invócala desde una máquina
checkout, pasándole el total porinputy recibiendo el recibo poronDone. - Construye el
contextdel hijo a partir de suinputy verifica que el padre no puede leer variables internas del hijo, solo suoutput. - Añade
onSnapshoten el padre para reflejar en el contexto el subestado actual del hijo y píntalo en una barra de progreso. - Envía un evento CANCELAR al hijo con
sendTodesde una transición del padre, y devuelve un evento del hijo al padre consendParent. - Argumenta, ante un flujo concreto tuyo, dónde trazarías la frontera entre padre e hijo y por qué esa y no otra.