wandres.dev
XSTATE: EFECTOS · invoke y promesas

Actor logics: envolver cualquier asincronía

Un actor en XState es dos cosas: una identidad dentro del sistema y una lógica que define cómo reacciona al tiempo y a los eventos. XState v5 provee cuatro creadores de lógica que cubren el espectro completo de la asincronía: fromPromise para el valor único que llega tarde, fromObservable para el flujo de valores, fromCallback para la fuente imperativa y bidireccional, y fromTransition para el reductor con estado. Dominar cuál elegir es dominar cómo cualquier efecto del mundo real entra, ordenado, dentro de una máquina de estado.

⏱ 18 min

La lección anterior mostró que invoke ata un actor a un estado, pero dejó una pregunta abierta: ¿de qué está hecho un actor? La respuesta separa dos conceptos que el modelo de actores mantiene deliberadamente distintos. Una cosa es la identidad —el buzón, el id, el lugar en el sistema— y otra la lógica de actor: la receta que dicta cómo esa entidad procesa el tiempo, produce valores y responde a los mensajes. XState v5 estandariza esa receta con cuatro constructores, cada uno pensado para una forma concreta de asincronía. No son cuatro atajos arbitrarios: son una taxonomía casi exhaustiva de cómo el mundo exterior entrega información a un programa —de golpe, en goteo, por empuje o por reducción— y aprender a mapear cada efecto real al creador correcto es lo que convierte a XState en un orquestador universal de lo asíncrono.

🎯 Al terminar esta lección sabrás
  • Separar la identidad de un actor de su lógica de actor.
  • Conocer los cuatro creadores: fromPromise, fromObservable, fromCallback y fromTransition.
  • Elegir el creador correcto según la forma de la asincronía a envolver.
  • Entender que cualquier fuente asíncrona puede volverse un actor invocable.

Identidad y lógica: dos ejes independientes

En el modelo de actores, un actor es una entidad con un buzón que procesa mensajes uno a uno. Pero cómo los procesa —qué hace al recibirlos, qué emite, cuándo termina— no forma parte de la identidad: es su lógica. XState materializa esta separación con la función createActor, que toma una lógica y le da vida como instancia. Los cuatro creadores from* producen justamente lógicas; invoke y spawn les dan identidad dentro de una máquina.

La ventaja de separar ambos ejes es la componibilidad. La misma lógica puede instanciarse muchas veces con distintos input, probarse de forma aislada sin máquina alguna, o intercambiarse por un doble en los tests. La lógica es un valor puro y reutilizable; la identidad es lo que la ancla en un sistema concreto.

🎯

fromPromise

Un valor único que llega tarde. La asincronía puntual: una petición, una lectura de disco, cualquier async que resuelve una vez.

🌊

fromObservable

Un flujo de valores en el tiempo. Envuelve un observable de RxJS o cualquier fuente que emite muchas veces y quizá completa.

🔌

fromCallback

Una fuente imperativa y bidireccional. El escape hatch: sockets, listeners del DOM, APIs de callback que emiten y reciben.

🔁

fromTransition

Un reductor con estado. Una función estado más evento igual a estado nuevo, como un mini Redux vuelto actor.

El valor único y el flujo: fromPromise y fromObservable

fromPromise es el creador más común. Recibe una función asíncrona y produce una lógica que, al arrancar, ejecuta la promesa. Cuando resuelve, el actor termina y su valor viaja al padre como el output del evento de finalización. Cuando rechaza, termina en error y el padre lo recibe por onError. Un detalle crucial de v5: la función recibe un signal, un AbortSignal que el runtime dispara si el actor se detiene antes de resolver, lo que permite cancelar la petición subyacente.

import { fromPromise } from 'xstate'

const cargarUsuario = fromPromise(
  async ({ input, signal }: { input: { id: string }; signal: AbortSignal }) => {
    const res = await fetch(`/api/users/${input.id}`, { signal })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return (await res.json()) as Usuario
  },
)

fromObservable cubre el caso en que la fuente no entrega un valor, sino muchos a lo largo del tiempo. Envuelve cualquier observable —de RxJS o compatible— y el actor emite una instantánea por cada valor. El padre reacciona a esas emisiones con onSnapshot, y onDone se dispara solo si el observable completa. Es la vía natural para relojes, streams de posición, o cualquier telemetría continua.

import { fromObservable } from 'xstate'
import { interval, map } from 'rxjs'

const reloj = fromObservable(({ input }: { input: { periodo: number } }) =>
  interval(input.periodo).pipe(map((n) => ({ tick: n }))),
)
📝
onDone en observables es opcional

Un fromPromise siempre termina: resuelve o rechaza. Un fromObservable, en cambio, puede no completar nunca —un reloj infinito no llama a onDone—. Por eso, con flujos, la reacción interesante casi siempre es onSnapshot, no onDone. Modela onDone solo si tu fuente tiene un final natural.

La fuente imperativa y el reductor: fromCallback y fromTransition

fromCallback es la válvula de escape para todo lo que no encaja en una promesa ni en un observable: APIs imperativas, bidireccionales, con suscripción y limpieza manual. Su función recibe sendBack para emitir eventos al padre y receive para escuchar los que el padre le dirija; lo que retorna es la función de limpieza que el runtime ejecutará al detener el actor. Es el creador con el que se envuelven WebSockets, EventSource o listeners del DOM.

import { fromCallback } from 'xstate'

const socket = fromCallback(
  ({ sendBack, receive, input }: { input: { url: string } }) => {
    const ws = new WebSocket(input.url)
    ws.onmessage = (e) => sendBack({ type: 'MENSAJE', datos: e.data })
    ws.onerror = () => sendBack({ type: 'CAIDA' })
    receive((evento) => {
      if (evento.type === 'ENVIAR') ws.send(evento.payload)
    })
    return () => ws.close()
  },
)

fromTransition es el más peculiar: convierte una función reductora pura en un actor. Recibe la reducción estado, evento y el estado inicial, y produce un actor cuyo estado interno evoluciona con cada evento que le llegue, exponiéndolo como instantánea. Es, en esencia, un store de Redux instanciable como actor, útil para encapsular una porción de lógica de reducción reutilizable dentro del sistema.

import { fromTransition } from 'xstate'

const contador = fromTransition(
  (estado, evento: { type: 'INC' } | { type: 'DEC' }) => {
    switch (evento.type) {
      case 'INC':
        return { n: estado.n + 1 }
      case 'DEC':
        return { n: estado.n - 1 }
      default:
        return estado
    }
  },
  { n: 0 },
)
⚠️
fromCallback no termina solo

A diferencia de fromPromise, un fromCallback no tiene un final natural: vive hasta que el estado que lo invoca se abandona o hasta que emite un evento que provoque una transición. No esperes que dispare onDone por su cuenta. Si necesitas señalar terminación, hazlo explícito enviando un evento con sendBack y transicionando fuera del estado.

La taxonomía como criterio de diseño

Los cuatro creadores no son intercambiables: cada uno codifica una forma distinta de asincronía, y elegir mal se paga en fricción. Un flujo forzado dentro de un fromPromise pierde todos sus valores menos el primero; una petición envuelta en fromCallback te obliga a gestionar a mano una cancelación que fromPromise te regala con su signal. La pregunta guía es siempre la misma: ¿cuántos valores entrega la fuente y en qué dirección viaja la información?

flowchart TD
Q1{cuantos valores emite} -->|uno| P[fromPromise]
Q1 -->|muchos| Q2{necesitas enviarle eventos}
Q2 -->|no| O[fromObservable]
Q2 -->|si| C[fromCallback]
Q1 -->|es una reduccion pura| T[fromTransition]
style P fill:#a6e3a1,color:#11111b
style O fill:#89b4fa,color:#11111b
style C fill:#fab387,color:#11111b
style T fill:#cba6f7,color:#11111b
Toda asincronía es un actor esperando su forma

La lección honda de los actor logics es que la asincronía no es un zoológico de mecanismos incompatibles —promesas por aquí, streams por allá, event emitters por el otro lado— sino un continuo con muy pocas dimensiones. Solo hay dos preguntas que de verdad importan: cuántos valores produce la fuente y si la comunicación es de una vía o de dos. Un valor de ida es una promesa; muchos de ida son un flujo; muchos en ambos sentidos son un callback; y una evolución interna gobernada por eventos es una transición. XState no inventó esta taxonomía, la heredó del modelo de actores de Hewitt y de décadas de teoría de la concurrencia, pero la vuelve práctica al darte un constructor para cada casilla y un mismo protocolo de vida para todas: nacen, procesan, quizá terminan, y siempre se limpian. El efecto de aprender esto trasciende a XState. Empiezas a mirar cualquier API asíncrona —una que jamás oíste nombrar— y ya sabes en qué casilla cae, cómo envolverla, cómo cancelarla y cómo hablar con ella. La fragmentación del asincronismo en JavaScript, que durante años obligó a aprender un patrón distinto por librería, se disuelve en un modelo único: todo lo que ocurre en el tiempo es un actor, y solo hay cuatro maneras de que un actor exista. Quien ve el mundo así deja de pegar librerías con cinta adhesiva y empieza a orquestar procesos.

⚔️ Envuelve las cuatro formas
  1. Escribe un fromPromise que lea una API con signal y comprueba, al cancelar, que la petición se aborta de verdad en la pestaña de red.
  2. Envuelve un interval de RxJS con fromObservable e invócalo con onSnapshot para pintar un contador que late.
  3. Modela un WebSocket con fromCallback: emite mensajes con sendBack y acepta un evento ENVIAR con receive.
  4. Construye un fromTransition que sea un reductor de carrito y pruébalo con createActor sin ninguna máquina alrededor.
  5. Toma una API de callback que conozcas —geolocalización, por ejemplo— y decide de forma razonada cuál de los cuatro creadores le corresponde.
  6. Justifica por qué meter un flujo infinito dentro de un fromPromise es un error de tipo, no solo de estilo.