wandres.dev
XSTATE: EFECTOS · invoke y promesas

onDone, onError e input: el diálogo con el actor

Invocar un actor abre un canal de resultados con la máquina que lo hospeda. onDone y onError son las dos transiciones que ese canal habilita: la máquina decide a dónde ir según el actor termine bien o falle, y el resultado o el error viajan como carga del evento sintético. En la otra dirección, input es el argumento con el que el actor nace, calculado desde el contexto en el instante de la creación. Entender la forma exacta de estos eventos y el momento preciso en que se evalúa input separa las máquinas robustas de las que fallan en silencio.

⏱ 17 min

Un actor invocado no es un cálculo que ocurre en el vacío: es una conversación con la máquina que lo hospeda. Esa conversación tiene una entrada y dos salidas. La entrada es input, el argumento con el que el actor nace, extraído del contexto en el momento exacto de su creación. Las salidas son onDone y onError: dos transiciones que la máquina toma según el actor culmine con éxito o se rompa. La clave que se suele pasar por alto es que ni el resultado ni el error llegan por un canal mágico: llegan como eventos sintéticos que XState fabrica y encola, con una forma y un nombre precisos. Tratar la finalización de un actor como lo que realmente es —un evento más en la máquina, indistinguible en su mecánica de un clic del usuario— es lo que permite razonar sobre el asincronismo con el mismo rigor que sobre cualquier otra transición.

🎯 Al terminar esta lección sabrás
  • Ver onDone y onError como transiciones disparadas por eventos sintéticos.
  • Conocer la forma exacta de esos eventos: output en el éxito, error en el fallo.
  • Entender input como el argumento del actor, evaluado en el instante de creación.
  • Diseñar el flujo de resultado y de error como parte del grafo de estados, no como un añadido.

El resultado es un evento, no un retorno

En programación imperativa, una función asíncrona devuelve un valor y tú lo recibes en el punto de la llamada. En una máquina de estado no hay punto de llamada: hay estados y transiciones. Por eso XState no te “devuelve” el resultado de un actor; lo convierte en un evento y lo inyecta en la máquina. Cuando un fromPromise resuelve, el runtime encola un evento cuyo tipo es xstate.done.actor.<id> y cuya carga es el valor resuelto. onDone no es más que azúcar para escribir una transición que escucha ese evento.

// esto:
cargando: {
  invoke: { id: 'carga', src: 'cargarUsuario', onDone: 'exito' }
}
// es equivalente, en esencia, a esto:
cargando: {
  invoke: { id: 'carga', src: 'cargarUsuario' },
  on: { 'xstate.done.actor.carga': 'exito' }
}

La consecuencia práctica es profunda: la finalización de un actor participa del mismo motor de transiciones que todo lo demás. Puede tener guardas, puede tener acciones, puede competir con otras transiciones del estado. No es un mecanismo aparte; es un evento con un nombre reservado.

⚠️
Dos onDone que se escriben igual pero no son el mismo

Cuidado con una homonimia traicionera. El onDone de un invoke se dispara cuando el actor invocado termina; el onDone de un estado compuesto se dispara cuando ese estado alcanza su subestado final. Uno habla de un actor, el otro de una región, y escuchan eventos sintéticos distintos —xstate.done.actor.<id> frente a xstate.done.state.<id>—. Saber cuál estás declarando evita esas transiciones fantasma que nunca disparan porque escuchaban el evento equivocado.

La forma de los eventos sintéticos

Saber qué contiene cada evento es lo que te permite extraer el resultado sin adivinar. El evento de éxito lleva el valor en output; el de error, la causa en error. XState v5 tipa ambos si declaraste bien los actors en setup, de modo que event.output tiene el tipo del valor resuelto por la promesa.

import { assign } from 'xstate'

cargando: {
  invoke: {
    id: 'carga',
    src: 'cargarUsuario',
    input: ({ context }) => ({ id: context.userId }),
    onDone: {
      target: 'exito',
      actions: assign({ usuario: ({ event }) => event.output }),
    },
    onError: {
      target: 'fallo',
      actions: assign({ error: ({ event }) => event.error }),
    },
  },
}

Un apunte de tipos que ahorra sorpresas: event.error es de tipo unknown, porque en JavaScript se puede lanzar cualquier valor y no solo un Error. Antes de leer un .message conviene estrechar el tipo o normalizar el error en la propia acción. event.output, en cambio, sí llega tipado con el valor que tu lógica promete devolver, siempre que registraras el actor en setup.actors con sus tipos declarados.

sequenceDiagram
participant M as maquina
participant A as actor invocado
M->>A: entra en cargando y crea el actor con input
A-->>A: ejecuta la promesa
alt resuelve
  A->>M: evento done con output
  M->>M: onError no aplica y transiciona a exito
else rechaza
  A->>M: evento error con error
  M->>M: transiciona a fallo
end
⚠️
Sin onError, un rechazo no es inofensivo

Si un fromPromise rechaza y el estado no define onError, la máquina no ignora el fallo en silencio de manera benigna: el actor pasa a estado de error y, según la configuración, puede propagarse hacia arriba o dejar la máquina detenida. Nunca invoques una promesa que pueda fallar sin declarar onError. Un actor sin manejo de error es el equivalente en statecharts a una promesa sin catch.

input: el argumento congelado en la creación

Si onDone y onError son la salida, input es la entrada. Es una función que recibe el contexto y el evento que provocó la transición hacia el estado, y devuelve el argumento con el que el actor nace. El matiz decisivo es temporal: input se evalúa UNA sola vez, en el instante en que el actor se crea —es decir, al entrar en el estado—. No es reactivo. Si el contexto cambia después, el actor ya nació con el valor que había en ese momento y no se entera de la mutación.

input: ({ context, event }) => ({
  id: context.userId,
  intento: context.intentos + 1,
  disparadoPor: event.type,
})

Esta instantaneidad es una virtud, no una limitación: garantiza que el actor opera sobre una foto coherente del estado, inmune a mutaciones concurrentes durante su ejecución. Si necesitas que el actor reaccione a cambios posteriores, ese es justamente el caso que input no cubre: entonces quieres enviarle eventos con sendTo, no recalcular input. Confundir ambos mecanismos —esperar que input se reevalúe— es un error clásico de quien llega desde modelos reactivos.

💡
input recibe también el evento que entró al estado

Como input recibe el event de la transición, puedes pasarle al actor datos que venían en el propio evento sin tener que guardarlos antes en el contexto. Es el atajo idóneo cuando el argumento del actor viene directamente del evento del usuario y no necesita persistir.

Diseñar el flujo de resultado en el grafo

Como onDone y onError son transiciones plenas, puedes tejer con ellas flujos ricos que serían enrevesados con promesas encadenadas. Una guarda en onDone bifurca según el contenido del resultado; una transición de error hacia un estado que reintenta con retroceso exponencial modela la resiliencia de forma declarativa; un resultado puede a su vez desencadenar la invocación del siguiente actor, componiendo una secuencia asíncrona como una cadena de estados.

onDone: [
  { target: 'vacio', guard: ({ event }) => event.output.length === 0 },
  { target: 'conDatos', actions: assign({ items: ({ event }) => event.output }) },
],

Ese fragmento decide el destino según el resultado: si la lista vino vacía va a un estado, si trajo datos va a otro. La lógica de ramificación que en código imperativo serían condicionales dispersos tras el await aquí es estructura visible del grafo, verificable de un vistazo.

La composición secuencial surge con la misma naturalidad. Si el resultado de un actor es la entrada del siguiente, encadenas estados: el onDone del primero guarda su output en el contexto y transiciona al estado que invoca al segundo, cuyo input lee ese contexto. Lo que en promesas sería un then tras otro se convierte en una ruta de estados donde cada paso es inspeccionable y cada fallo intermedio tiene su propio onError y su propio destino.

validando: {
  invoke: {
    src: 'validar',
    input: ({ context }) => ({ datos: context.borrador }),
    onDone: {
      target: 'guardando',
      actions: assign({ validado: ({ event }) => event.output }),
    },
    onError: 'invalido',
  },
},
guardando: {
  invoke: {
    src: 'guardar',
    input: ({ context }) => ({ registro: context.validado }),
    onDone: 'guardado',
    onError: 'errorAlGuardar',
  },
},
La finalización de un actor es un evento, y por eso el asincronismo se vuelve razonable

El error mental más costoso al modelar asincronía es tratarla como una excepción al flujo normal del programa: el hilo principal por un lado y, aparte, unos callbacks que resuelven “cuando toque” y a los que cuesta seguir la pista. La revelación de onDone y onError es que en una máquina de estado no existe tal excepción: la resolución de una promesa, el fallo de una petición, el clic de un usuario y el vencimiento de un temporizador son todos exactamente la misma cosa —un evento que entra a la máquina y desencadena una transición según el estado actual—. No hay dos regímenes, uno síncrono y otro asíncrono; hay un solo régimen, el de los eventos, y el tiempo es solo la razón por la que unos eventos llegan más tarde que otros. Cuando aceptas que xstate.done.actor.carga es un evento tan de primera clase como CLIC, ganas la capacidad de razonar sobre todo tu programa con una única herramienta mental: dado este estado y este evento, ¿a dónde voy? El asincronismo deja de ser una dimensión paralela que se cuela por los bordes y pasa a estar completamente domesticado dentro del grafo, con guardas, acciones y destinos explícitos. Y en la otra dirección, input te enseña la disciplina inversa: el actor nace con una foto del mundo, no con una suscripción a él, y esa inmutabilidad de su argumento es lo que hace que su ejecución sea predecible aunque el contexto siga cambiando a su alrededor.

⚔️ Modela el diálogo completo
  1. Invoca un fromPromise y extrae su valor con assign desde event.output en onDone.
  2. Provoca un rechazo y captura la causa en el contexto desde event.error en onError; comprueba qué pasa si quitas onError.
  3. Escribe la versión “cruda” de onDone usando on con el tipo xstate.done.actor.<id> y verifica que se comporta igual.
  4. Usa una guarda en onDone para bifurcar entre un estado vacio y otro conDatos según la longitud del resultado.
  5. Haz que input dependa del event que entró al estado y pásale un dato del evento sin guardarlo antes en el contexto.
  6. Muta el contexto justo después de crear el actor y demuestra que input no se reevalúa: el actor conserva su foto original.