wandres.dev
XSTATE: EL ACTOR MODEL · actores y mensajes

El actor como unidad central en XState v5

El gran giro de XState v5 respecto a v4: el actor deja de ser un caso especial y pasa a ser la unidad central. Toda lógica —una máquina de estados, una promesa, un callback, un observable, una función de transición— se envuelve en un actor y habla el mismo protocolo: recibe eventos, expone una snapshot y notifica a sus suscriptores. Esta lección separa la lógica del actor (la definición reutilizable) de la instancia viva que produce createActor, recorre los constructores fromPromise, fromCallback, fromObservable y fromTransition, y muestra por qué unificar todo bajo una sola interfaz vuelve el sistema componible.

⏱ 17 min

En XState v4 la máquina de estados era el centro y los actores eran un añadido para casos avanzados. La v5 invierte la jerarquía: ahora el actor es la unidad primaria y la máquina es apenas una de las formas que un actor puede tomar. Una promesa es un actor. Un callback es un actor. Un stream es un actor. Esta unificación no es cosmética: significa que todo en tu sistema —desde una petición HTTP hasta un statechart de mil estados— se crea, se arranca, se observa y se detiene exactamente igual. Aprendes un protocolo y lo usas para todo.

🎯 Al terminar esta lección sabrás
  • Distinguir la lógica del actor (la definición) de la instancia viva que produce createActor.
  • Dominar el protocolo común: start, send, getSnapshot y subscribe.
  • Conocer los constructores fromPromise, fromCallback, fromObservable y fromTransition.
  • Entender por qué envolver toda lógica en actores hace el sistema uniformemente componible.

Lógica frente a actor: la clase y la instancia

La distinción más importante del nivel es también la más fácil de pasar por alto. La lógica del actor es una definición: describe cómo se comporta un actor, qué eventos entiende y cómo evoluciona su estado. Es pura y reutilizable, como un plano. El actor es la instancia viva: una ejecución concreta de ese plano, con su propio buzón, su propio estado actual y su propio ciclo de vida. Un plano puede dar lugar a muchos actores, igual que una clase da lugar a muchos objetos.

import { createActor, createMachine, fromPromise } from "xstate"

// Una maquina es una FORMA de logica de actor: un plano, no un actor vivo.
const semaforo = createMachine({
  id: "semaforo",
  initial: "verde",
  states: {
    verde: { on: { CAMBIAR: "amarillo" } },
    amarillo: { on: { CAMBIAR: "rojo" } },
    rojo: { on: { CAMBIAR: "verde" } },
  },
})

// Una promesa tambien es logica de actor: otro plano, misma categoria.
const cargarUsuario = fromPromise(async ({ input }) => {
  const res = await fetch(`/api/usuario/${input.id}`)
  return res.json()
})

Ni semaforo ni cargarUsuario están vivos todavía. Son descripciones inertes. Para darles vida hace falta instanciarlas, y ahí es donde entra el segundo concepto. Confundir el plano con la instancia es el error de novato que produce bugs desconcertantes: reutilizar una lógica en dos actores es sano y esperado; intentar tratar la lógica como si tuviera estado propio no tiene sentido, porque el estado vive en la instancia.

createActor: el protocolo común

createActor toma cualquier lógica y devuelve un actor: una entidad viva que respeta al pie de la letra los tres verbos de Hewitt. Todos los actores, sin importar de qué lógica nazcan, exponen la misma interfaz mínima. Esa uniformidad es la que permite tratar una promesa y un statechart con el mismo código.

const actor = createActor(semaforo)

actor.subscribe((snapshot) => {          // observar: reacciona a cada cambio
  console.log("estado actual:", snapshot.value)
})

actor.start()                            // arrancar: ahora esta vivo y escucha
actor.send({ type: "CAMBIAR" })          // enviar: depositas un evento en su buzon
const snap = actor.getSnapshot()         // leer: consultas su estado sin tocarlo
actor.stop()                             // detener: cierra su ciclo de vida

Cuatro operaciones cubren todo el trato con un actor. start lo pone en marcha; send deposita un evento en su buzón, siempre de forma asíncrona respecto al procesamiento; getSnapshot devuelve una foto inmutable de su estado en este instante; subscribe registra un observador que recibirá cada nueva snapshot. Nunca lees ni escribes el interior del actor directamente: hablas con él por su superficie, exactamente como el modelo de Hewitt exige.

📝
La snapshot es una foto, no el actor

getSnapshot no te da acceso al actor, te da un valor inmutable que describe su estado en ese momento: su value, su context, su status y qué eventos puede aceptar. Guardar una snapshot y consultarla más tarde te muestra el pasado, no el presente; para saber el estado actual vuelves a pedir la foto o te suscribes. Esta separación entre la entidad viva y sus fotos inmutables es la misma disciplina de la inmutabilidad del nivel 7 aplicada a los actores: el actor cambia, pero cada snapshot es un hecho congelado.

Toda lógica es un actor

Aquí se ve la potencia de la unificación. XState ofrece un constructor por cada forma habitual de lógica asíncrona, y todos producen lógica de actor intercambiable: la misma que consumen createActor, spawn e invoke.

flowchart TD
L1[fromPromise] --> CA[createActor]
L2[fromCallback] --> CA
L3[fromObservable] --> CA
L4[createMachine] --> CA
CA --> AC[actor con start send getSnapshot subscribe]
style CA fill:#cba6f7,color:#11111b
style AC fill:#a6e3a1,color:#11111b
🎯

fromPromise

Envuelve una operación asíncrona que resuelve una sola vez: una petición HTTP, una lectura de disco. El actor termina cuando la promesa resuelve o rechaza.

📞

fromCallback

Para lógica imperativa de larga vida con envío bidireccional: suscripciones, temporizadores, websockets. Recibe eventos y emite otros con sendBack.

🌊

fromObservable

Convierte un stream —un Observable de RxJS, por ejemplo— en un actor cuya snapshot es el último valor emitido por la fuente.

⚙️

fromTransition

La lógica más simple: un reducer puro. Recibe estado y evento y devuelve el estado siguiente. Un actor que es, literalmente, una función de transición.

import { fromCallback, fromTransition } from "xstate"

// fromCallback: un reloj que emite un TIC cada segundo hacia quien lo escuche.
const reloj = fromCallback(({ sendBack }) => {
  const id = setInterval(() => sendBack({ type: "TIC" }), 1000)
  return () => clearInterval(id)         // limpieza al detener el actor
})

// fromTransition: un contador que es solo un reducer puro.
const contador = fromTransition(
  (estado, evento) => {
    if (evento.type === "SUMAR") return { total: estado.total + 1 }
    return estado
  },
  { total: 0 },                          // estado inicial
)

Cada constructor cubre un patrón asíncrono distinto, pero todos desembocan en la misma categoría. La tabla resume cuándo alcanza cada uno y cómo termina su ciclo de vida, porque elegir mal el constructor es la fuente más común de actores que nunca se detienen o que se detienen antes de tiempo.

Constructor Para qué Cómo termina
fromPromise Una operación async de una sola vez Al resolver o rechazar la promesa
fromCallback Suscripciones y efectos de larga vida Cuando lo detienen; ejecuta su limpieza
fromObservable Un stream de valores en el tiempo Al completar el observable o al detenerlo
fromTransition Un reducer puro sin async Cuando lo detienen explícitamente
createMachine Un statechart con estados y guardas Al llegar a un estado final o al detenerlo

Que un reducer puro y un statechart jerárquico sean ambos “lógica de actor” no es un detalle de implementación: es lo que permite empezar simple y crecer sin cambiar de paradigma. Modelas un contador con fromTransition; si mañana necesita estados y guardas, lo asciendes a createMachine sin tocar cómo lo instancias, lo observas ni lo comunicas con el resto. El molde que lo rodea no se entera del cambio.

Observar un actor de promesa deja ver el protocolo en acción: la misma snapshot que en una máquina describe un value, aquí describe el progreso de la carga hasta terminar con su salida o su error. El código que la observa es idéntico al que observaría un semáforo.

const actor = createActor(cargarUsuario, { input: { id: 42 } })

actor.subscribe((snap) => {
  if (snap.status === "active") console.log("cargando...")
  if (snap.status === "done") console.log("listo:", snap.output)
  if (snap.status === "error") console.log("fallo:", snap.error)
})

actor.start()   // dispara la promesa; las snapshots van llegando solas
💡
subscribe refleja estado, on señala acontecimientos

Suscribirse te entrega cada snapshot, ideal para reflejar el estado en una UI. Pero si solo te interesan momentos concretos —una carga que terminó, un error—, filtra por status dentro del observador o usa el actor.on de la lección 4. Suscribirte para reaccionar a un único acontecimiento y disparar efectos en cada snapshot intermedia es la causa habitual de renders de más y de trabajo repetido.

El actor es la interfaz universal del estado en marcha

La decisión de diseño más profunda de XState v5 es tratar al actor como una interfaz, no como una clase. Una promesa, un websocket, un reducer y un statechart de Harel no tienen nada en común por dentro; por fuera, en la v5, son idénticos: se crean con la misma familia de constructores, se instancian con createActor, se arrancan con start, reciben eventos con send, exponen su estado con getSnapshot y avisan de sus cambios con subscribe. Esa uniformidad es lo que convierte el paso de mensajes de Hewitt en algo práctico a escala de una aplicación entera: puedes tener un actor de actores, un statechart que invoca una promesa que a su vez habla con un callback, y en cada frontera el contrato es el mismo. No hay que aprender un protocolo para las peticiones, otro para los temporizadores y otro para las máquinas. Hay un solo protocolo, el del actor, y todo lo demás son formas que ese protocolo adopta. Cuando interiorizas que en XState v5 no existen “las máquinas y además los actores”, sino solo actores —algunos de los cuales resultan ser máquinas—, el resto del framework deja de ser un catálogo de funciones sueltas y se vuelve una sola idea aplicada con disciplina.

⚔️ Instancia y observa
  1. Crea la máquina semaforo, instánciala con createActor, suscríbete e imprime cada cambio de value al enviarle varios CAMBIAR.
  2. Escribe una lógica con fromPromise que cargue datos de una API y observa cómo su snapshot pasa de estado inicial a resuelto.
  3. Modela un contador con fromTransition y luego reescríbelo como createMachine; comprueba que el código que lo instancia y observa no cambia.
  4. Usa fromCallback para crear un reloj que emita TIC cada segundo y detenlo con stop; verifica que la función de limpieza se ejecuta.
  5. Completa la tabla de constructores con una columna nueva: qué recibe cada uno como input al arrancar.
  6. Explica con tus palabras la diferencia entre la lógica del actor y el actor, y por qué una sola lógica puede dar lugar a muchos actores vivos.