onDone y output: reaccionar al final y devolver un resultado
Un final que nadie escucha es un final inútil. Esta lección cubre los dos mecanismos que convierten la terminación en información aprovechable: `onDone`, la transición que el motor dispara mediante un evento sintético cuando una región o un actor invocado concluye, y `output`, el valor que ese final produce y transporta. Se detallan la forma de los eventos sintéticos, la diferencia entre el `output` de un estado final y el de la máquina raíz, cómo varios finales distintos generan un resultado discriminado, y por qué una máquina con final es indistinguible de una promesa.
Marcar un estado como terminal declara que algo acabó, pero por sí solo no comunica nada hacia arriba ni entrega ningún resultado; es una afirmación sin destinatario. Los dos mecanismos que completan la figura son complementarios y se usan casi siempre juntos. El primero es onDone, una transición que no responde a ningún evento que tú envíes sino a un evento sintético que el propio motor emite en el instante en que una región alcanza su final o un actor invocado se detiene con éxito; es el canal por el que un subproceso avisa a su contenedor. El segundo es output, el valor que ese final produce y que viaja dentro del evento sintético hasta quien escucha. Juntos convierten la relación entre un proceso y su subproceso en algo que ya sabes leer: una llamada que devuelve. Y una vez tienes llamadas que devuelven, tienes composición, que es lo único que hace falta para construir sistemas grandes sin que se conviertan en una máquina única e ilegible.
- Identificar la forma de los eventos sintéticos que disparan un
onDoney por qué no se envían a mano. - Declarar
outputen un estado final y en la raíz, distinguiendo qué recibe cada uno. - Leer el resultado desde el evento del
onDoney desde el snapshot de un actor terminado. - Modelar varios desenlaces como un resultado discriminado que el consumidor pueda ramificar.
El evento que no puedes enviar
onDone no aparece bajo on sino como clave propia del estado, y esa colocación es una pista deliberada: no es un evento del dominio. El motor emite un evento sintético cuando una región termina, y su forma depende de quién terminó.
| Quién termina | Evento sintético | Dónde se declara la reacción |
|---|---|---|
| Un estado compuesto | done.state.<id> |
onDone del propio compuesto |
| Un estado paralelo | done.state.<id> |
onDone del paralelo, tras terminar todas sus regiones |
| Un actor invocado | done.invoke.<id> |
onDone dentro del bloque invoke |
| La máquina raíz | ninguno hacia dentro | El padre lo recibe como done.invoke si fue invocada |
La distinción entre done.state y done.invoke importa porque son dos formas distintas de contener un subproceso. La primera es contención estructural: el subproceso es una parte del árbol de estados, corre en el mismo actor y no tiene identidad propia. La segunda es contención por delegación: el subproceso es otro actor con su ciclo de vida, su contexto y su cola de eventos. El onDone uniforma ambas ante quien escucha —el padre reacciona igual— y esa uniformidad es lo que te permite empezar con un compuesto y extraerlo más tarde a una máquina invocada sin reescribir la lógica del padre.
Enviar a mano un evento con la forma done.state.algo para simular una terminación es un antipatrón que aparece más de lo que debería, normalmente en tests. Rompe la invariante central: un onDone significa que la región de verdad alcanzó su final, con todas las acciones de salida ejecutadas y todos los recursos liberados. Falsificar el evento produce un padre que cree que su hijo terminó mientras el hijo sigue corriendo, que es la clase de incoherencia que un modelo de estados existía para hacer imposible. Si en un test necesitas llegar al onDone, envía los eventos reales que llevan al final; si esa secuencia es incómoda de reproducir, el problema está en el diseño del subproceso, no en el test.
output: el valor de retorno
Una máquina que termina puede devolver algo, y ese algo se declara con output. Hay dos lugares donde aparece la propiedad y confundirlos es la fuente habitual de resultados que llegan vacíos.
import { createMachine } from 'xstate'
const checkout = createMachine({
id: 'checkout',
initial: 'carrito',
context: { total: 0, referencia: '' },
states: {
carrito: { on: { PAGAR: 'cobrando' } },
cobrando: {
on: { COBRADO: 'exito', RECHAZADO: 'rechazo' },
},
exito: {
type: 'final',
output: ({ context }) => ({ ok: true, referencia: context.referencia }),
},
rechazo: {
type: 'final',
output: { ok: false, motivo: 'rechazado por el emisor' },
},
},
output: ({ context }) => ({ referencia: context.referencia }),
})
El output de un estado final describe qué produce ese desenlace concreto y viaja dentro del evento sintético; es lo que permite que dos finales distintos entreguen resultados distintos. El output de la máquina raíz describe qué devuelve el actor entero, y se evalúa cuando la máquina alcanza un final de nivel superior; recibe el contexto y el evento que provocó la terminación, de modo que puede reenviar el resultado del final concreto o calcular uno propio. Ambos aceptan una función o un valor estático, y ambos se resuelven una sola vez, en el momento exacto de terminar.
Final con output
Describe el resultado de un desenlace concreto. Dos finales distintos pueden devolver formas distintas y el consumidor los discrimina.
Raíz con output
Describe el valor de retorno del actor completo. Es lo que verá quien lo invocó o quien lo espere como promesa.
Evento del onDone
Transporta el resultado hasta el padre. Se lee como event.output dentro de la transición o de sus acciones.
Del lado del que escucha, el resultado se lee en el evento. Una transición onDone puede llevar acciones, y esas acciones reciben el evento sintético con el resultado dentro, típicamente para guardarlo en el contexto del padre antes de continuar.
import { assign, createMachine } from 'xstate'
const tienda = createMachine({
id: 'tienda',
initial: 'comprando',
context: { ultimoPedido: null },
states: {
comprando: {
initial: 'checkout',
states: {
checkout: { on: { FIN: 'hecho' } },
hecho: { type: 'final', output: ({ context }) => context },
},
onDone: {
target: 'resumen',
actions: assign({ ultimoPedido: ({ event }) => event.output }),
},
},
resumen: {},
},
})
El fallo más común con este mecanismo no produce un error sino un undefined silencioso: el padre transiciona correctamente en su onDone pero event.output no trae nada. La causa casi siempre es que el final alcanzado no declaraba output, o que se declaró en la raíz esperando que el compuesto interno lo heredase. No hay herencia: cada final decide qué produce, y un final sin output produce indefinido con toda normalidad, porque terminar sin devolver nada es un caso legítimo. Cuando el resultado importa, decláralo en el final concreto que lo genera y compruébalo en el onDone, no aguas abajo, donde el indefinido ya se habrá propagado a tres sitios.
Del final al await
Un actor con final es, sin metáfora de por medio, un cómputo asíncrono con resultado. La equivalencia es tan exacta que la librería la expone directamente: toPromise devuelve una promesa que se resuelve con el output en cuanto el actor alcanza done.
import { createActor, toPromise } from 'xstate'
const actor = createActor(checkout).start()
const resultado = await toPromise(actor)
// resultado es lo que devolvio el output de la raiz
console.log(resultado.referencia)
La consecuencia práctica es que una máquina con final se integra en código asíncrono corriente sin ceremonia: puedes esperarla, componerla con otras promesas o pasarla a cualquier utilidad que acepte una tarea. La consecuencia conceptual es más interesante: una máquina que nunca termina no puede tener promesa asociada, porque una promesa sin resolución posible no significaría nada. Terminar no es un detalle del ciclo de vida, es el requisito para que la máquina tenga un tipo de retorno.
flowchart TD A[hijo alcanza estado final] --> B[motor calcula el output del final] B --> C[emite evento sintetico done] C --> D[padre dispara su onDone] D --> E[acciones leen event output] E --> F[padre transiciona y continua] style A fill:#f9e2af,color:#11111b style C fill:#89dceb,color:#11111b style F fill:#a6e3a1,color:#11111b
Cuando un subproceso puede terminar de varias maneras, la tentación es modelar un único final y meter el desenlace en un campo del contexto. Es peor por dos razones. Primero, pierde la información en el diagrama: viendo el statechart ya no distingues los desenlaces, hay que leer el código de las acciones. Segundo, produce un tipo de resultado laxo donde todos los campos son opcionales, en lugar de una unión discriminada donde cada rama trae exactamente lo que su desenlace produce. Con finales separados y output por final, el consumidor recibe un valor que puede ramificar con seguridad de tipos, y el compilador le exige tratar cada caso. Es el mismo argumento contra los booleanos correlacionados que abrió este track, aplicado ahora al valor de retorno en vez de al estado.
Merece la pena detenerse en lo que estos dos mecanismos hacen juntos, porque no es un detalle de API sino una traducción entre dos mundos de programación que solemos mantener separados. En el mundo síncrono, componer es trivial y lo hacemos sin pensar: una función llama a otra, se detiene mientras la otra trabaja, recibe su valor y continúa con él. Toda la estructura de un programa se apoya en esa operación. En el mundo reactivo esa operación no existe de forma natural: nadie se detiene a esperar, todo son eventos que llegan cuando llegan, y por eso la programación orientada a eventos degenera con tanta facilidad en telarañas de callbacks donde nadie sabe qué le sigue a qué. Lo que hacen onDone y output es reconstruir la operación de composición dentro del mundo reactivo, sin traicionarlo. El padre no se bloquea —sigue vivo, sigue recibiendo eventos, sigue pudiendo cancelar al hijo si hace falta— pero declara de antemano qué hará cuando el hijo termine y dónde pondrá su resultado. Eso es exactamente lo mismo que un retorno de función, salvo que en vez de suceder en la pila de llamadas sucede a lo largo del tiempo, y en vez de escribirse como una línea que sigue a otra se escribe como una transición que espera. Ahí está la razón profunda de que los statecharts escalen a sistemas que la programación por callbacks no aguanta: no es que tengan más funcionalidades, es que recuperan la composicionalidad. Y la composicionalidad es la única propiedad conocida que permite construir algo grande sin entenderlo entero a la vez, porque te deja razonar sobre una pieza sabiendo únicamente qué recibe y qué devuelve, exactamente como llevas haciendo toda tu vida con funciones y sin dudar ni un segundo de que es posible.
- Añade
outputa un estado final y comprueba en elonDonedel padre queevent.outputtrae el valor esperado. - Elimina ese
outputy observa el indefinido silencioso; describe dónde lo habrías detectado si no supieras la causa. - Modela dos finales distintos con formas de resultado diferentes y escribe en el padre una acción que ramifique sobre el campo discriminante.
- Declara además un
outputen la raíz que reenvíe el resultado del final concreto y léelo desde el snapshot del actor terminado. - Convierte esa máquina en promesa con
toPromisey espérala conawait; comprueba qué ocurre si la máquina no tiene ningún final alcanzable. - Escribe un test que llegue al
onDoneenviando los eventos reales y explica por qué falsificar el evento sintético invalidaría la prueba. - Toma un subproceso tuyo que hoy guarda su desenlace en un campo del contexto y reescríbelo como finales separados con
output; compara los dos diagramas.