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.
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.
- Distinguir la lógica del actor (la definición) de la instancia viva que produce
createActor. - Dominar el protocolo común:
start,send,getSnapshotysubscribe. - Conocer los constructores
fromPromise,fromCallback,fromObservableyfromTransition. - 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.
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
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.
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.
- Crea la máquina
semaforo, instánciala concreateActor, suscríbete e imprime cada cambio devalueal enviarle variosCAMBIAR. - Escribe una lógica con
fromPromiseque cargue datos de una API y observa cómo su snapshot pasa de estado inicial a resuelto. - Modela un contador con
fromTransitiony luego reescríbelo comocreateMachine; comprueba que el código que lo instancia y observa no cambia. - Usa
fromCallbackpara crear un reloj que emitaTICcada segundo y detenlo constop; verifica que la función de limpieza se ejecuta. - Completa la tabla de constructores con una columna nueva: qué recibe cada uno como
inputal arrancar. - 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.