La máquina como valor puro: definición y ejecución
La idea más importante de XState no es una API sino una separación: la máquina es un valor puro e inmutable —la lógica— y `createActor` es lo que la ejecuta. Esta lección desarrolla esa distinción con precisión: el ciclo de vida del actor como intérprete, por qué la función de transición es determinista, cómo calcular el estado siguiente sin runtime alguno con las funciones puras `initialTransition` y `transition`, cómo persistir y restaurar el estado de un actor, y qué gana el sistema —testabilidad, serialización, portabilidad— al mantener la lógica separada de su intérprete.
Toda la potencia de XState descansa sobre una frontera que es fácil pasar por alto: createMachine no ejecuta nada. Produce un valor —inmutable, serializable, sin identidad ni estado mutable— que describe un autómata. Ese valor es la lógica. Para ejecutarla hace falta un intérprete, y ese intérprete es createActor: toma la máquina y crea un actor vivo, con identidad propia, un buzón de eventos y suscriptores. La misma máquina puede dar lugar a mil actores, igual que una clase da lugar a mil instancias o una función pura se invoca mil veces. Separar la definición de la ejecución no es una decisión de estilo: es lo que vuelve a la lógica testable como dato, transportable entre entornos y determinista por construcción.
- Distinguir con precisión la máquina —valor puro— del actor —instancia en ejecución con
createActor—. - Manejar el ciclo de vida del actor:
start,subscribe,send,getSnapshotystop. - Calcular el estado siguiente sin ningún runtime usando las funciones puras
initialTransitionytransition. - Justificar por qué esta separación habilita testear la lógica, serializarla y ejecutarla en cualquier entorno.
Definición contra ejecución
La máquina es a un actor lo que una clase a un objeto, o una receta a la comida. La receta no se puede comer: describe. Cocinarla —crear el plato— es otro acto, y de una receta salen muchos platos que luego evolucionan por separado. createActor es ese acto de cocinar.
import { createActor } from 'xstate'
import { alternador } from './alternador'
const a1 = createActor(alternador).start()
const a2 = createActor(alternador).start()
a1.send({ type: 'ALTERNAR' })
a1.getSnapshot().value // 'activo'
a2.getSnapshot().value // 'inactivo'
Una sola definición, dos vidas. a1 avanzó y a2 no, porque el estado vive en el actor, no en la máquina. La máquina no tiene “un estado actual”: es una descripción atemporal de todos los estados posibles y los saltos entre ellos. El estado actual es propiedad del actor, expuesto siempre a través de su snapshot y nunca mutado desde fuera. Preguntar “en qué estado está la máquina” es, estrictamente, una pregunta mal planteada: la máquina no está en ningún estado, del mismo modo que una función no tiene un valor de retorno hasta que la invocas.
Esta multiplicidad es habitual, no una rareza: una misma máquina de sesión describe a todos los usuarios conectados, y cada usuario es un actor; una misma máquina de componente describe el comportamiento de un botón, y cada botón montado en la pantalla es un actor. Escribes la lógica una vez y la instancias tantas veces como haga falta, con la garantía de que todas las instancias obedecen exactamente las mismas reglas.
Un actor es una entidad del modelo de actores: tiene una dirección, un buzón donde encolar los eventos que le llegan, y un conjunto de suscriptores a los que notifica cada cambio. actor.subscribe(fn) registra un observador; actor.send(evento) deposita un evento en el buzón; actor.start() y actor.stop() gobiernan su ciclo de vida. Nada de esto pertenece a la máquina: la máquina no se suscribe ni se detiene porque no corre. Toda la maquinaria de tiempo de ejecución vive en el actor.
El ciclo de vida del actor
Si la máquina es la lógica, el actor es su intérprete, y como todo intérprete tiene un ciclo de vida explícito. createActor construye el actor pero no lo arranca; start lo pone en marcha y lo lleva a su estado inicial; subscribe registra un observador que recibe cada snapshot nuevo; send deposita un evento en su buzón; stop lo detiene y libera sus recursos.
import { createActor } from 'xstate'
import { alternador } from './alternador'
const actor = createActor(alternador)
const sub = actor.subscribe((snapshot) => {
console.log(snapshot.value)
})
actor.start() // imprime 'inactivo'
actor.send({ type: 'ALTERNAR' }) // imprime 'activo'
actor.send({ type: 'ALTERNAR' }) // imprime 'inactivo'
sub.unsubscribe()
actor.stop()
El snapshot que recibe el suscriptor es el estado completo en un instante: snapshot.value con el estado, snapshot.context con los datos, snapshot.status con la fase del ciclo de vida. Nunca lees ni escribes el estado del actor directamente; lo observas a través de sus snapshots y lo cambias a través de sus eventos. Esa disciplina —comunicación solo por mensajes, estado encapsulado— es la del modelo de actores, del que XState toma su nombre y su vocabulario.
Observar por snapshot admite las dos disciplinas que un track de reactividad distingue: subscribe es la vía push —el actor te notifica en cuanto cambia—, y getSnapshot es la vía pull —preguntas el estado justo cuando lo necesitas—. La integración de XState con un framework usa la primera para redibujar la interfaz en cada transición; un test o un registro puntual usan la segunda para leer el estado sin comprometerse a escuchar.
Determinismo: la transición es una función pura
En el corazón de la máquina hay una función de transición pura: dado un estado y un evento, devuelve el estado siguiente. Pura significa dos cosas —mismo estado más mismo evento produce siempre el mismo resultado, y calcularlo no observa ni altera nada del mundo exterior—. XState expone ese corazón directamente: initialTransition da el estado inicial y transition aplica un evento a un estado, sin crear ningún actor, sin buzón, sin suscriptores.
import { initialTransition, transition } from 'xstate'
import { alternador } from './alternador'
const [estado0] = initialTransition(alternador)
const [estado1] = transition(alternador, estado0, { type: 'ALTERNAR' })
const [estado2] = transition(alternador, estado1, { type: 'ALTERNAR' })
estado0.value // 'inactivo'
estado1.value // 'activo'
estado2.value // 'inactivo'
Esto es una máquina de estados usada como un reductor puro: (estado, evento) que devuelve el estado siguiente, exactamente la firma de un reducer de Redux o del useReducer de React. Cada llamada devuelve una tupla cuyo primer elemento es el snapshot resultante y el segundo, las acciones que un intérprete debería ejecutar —porque las acciones, al tener efectos, no forman parte del cálculo puro—. El determinismo es total: dada una secuencia de eventos, el estado final está fijado.
De ese determinismo se sigue una capacidad que las arquitecturas basadas en eventos persiguen desde siempre: si guardas la secuencia de eventos, puedes reconstruir cualquier estado reproduciéndola desde el principio. No necesitas serializar el estado, solo la historia de eventos que lo produjo. Rebobinar, viajar en el tiempo durante la depuración, o recalcular el estado en un servidor sin nada que “corra” son consecuencias directas de que la transición sea una función pura y no un proceso con memoria oculta.
La función de transición pura no ejecuta las acciones: las DESCRIBE y las devuelve. Separar “qué efectos hay que provocar” de “provocarlos” es lo que mantiene puro el cálculo del estado siguiente. El actor, que sí es un intérprete con permiso para tocar el mundo, es quien luego ejecuta esas acciones. Esta es la misma disciplina que separa un reducer puro de los efectos en las arquitecturas de estado modernas: el núcleo decide, el borde actúa.
Aunque el actor como tal no se serializa —tiene suscriptores y un buzón, cosas vivas—, su ESTADO sí. actor.getPersistedSnapshot() devuelve una instantánea serializable que puedes guardar y, más tarde, restaurar creando un actor con esa foto: createActor(maquina, { snapshot }). La lógica viaja como definición; el punto exacto del recorrido, como snapshot persistido. Juntos reconstruyen un actor justo donde lo dejaste, que es la base de la hidratación entre servidor y cliente y de recuperar una sesión interrumpida.
Qué se gana al separar lógica de intérprete
Que la máquina sea un valor puro no es una elegancia académica; tiene consecuencias prácticas que se acumulan.
Testable como dato
Verificas la lógica llamando a transition con eventos y comprobando el estado resultante. Sin renderizar, sin temporizadores, sin esperar a nada: aserciones puras sobre una función pura.
Serializable a JSON
La definición es una estructura de datos, no código con clausuras opacas. Por eso puede viajar a una herramienta que la dibuje o volver de un editor visual convertida en máquina.
Portable entre entornos
La misma máquina corre en el navegador, en el servidor durante el renderizado, o en un worker. La lógica no asume dónde se ejecuta porque no se ejecuta: la ejecuta quien la interpreta.
Cada una de estas propiedades es un corolario de la pureza. No podrías serializar la lógica si estuviera enredada con el estado mutable de una ejecución concreta; no podrías testearla sin runtime si necesitara un runtime para existir; no podrías moverla de entorno si supiera en cuál corre. La separación entre definición y ejecución es lo que hace posibles las tres a la vez, y por eso una misma máquina puede probarse en un test unitario, dibujarse en una herramienta y desplegarse en producción sin cambiar una sola línea.
Un test de la lógica, entonces, no arranca nada ni espera a nada: encadena transiciones puras y afirma sobre el resultado.
import { initialTransition, transition } from 'xstate'
import { alternador } from './alternador'
const [s0] = initialTransition(alternador)
const [s1] = transition(alternador, s0, { type: 'ALTERNAR' })
// aserciones puras, sin runtime ni temporizadores
expect(s0.value).toBe('inactivo')
expect(s1.value).toBe('activo')
Comparado con probar la interfaz que consume el estado, esto es otro orden de simplicidad: no hay que montar el componente, ni simular el reloj, ni esperar promesas. La lógica es una función, y una función se prueba con entradas y salidas.
La mayoría de las librerías de estado enredan la lógica con su ejecución: el objeto que define el comportamiento es también el que lo corre, guarda el estado actual y dispara los efectos. XState corta ese nudo, y esa incisión es su tesis entera. Una máquina es referencialmente transparente —es un valor que siempre significa lo mismo, sin un “ahora” ni un “aquí”—. Un actor es lo contrario: tiene identidad, vive en el tiempo, acumula historia, provoca efectos. Confundir ambos es el error conceptual que la v5 te empuja a no cometer al darles nombres y APIs distintas. Cuando internalizas que createMachine produce una descripción y createActor produce un intérprete de esa descripción, dejas de preguntarte “en qué estado está mi máquina” y empiezas a preguntar “en qué estado está este actor”, que es la única pregunta con respuesta. El determinismo se sigue gratis: si la transición es una función pura, la historia completa de un actor está determinada por su secuencia de eventos, y puedes reproducirla, auditarla o calcularla en cualquier lugar. Esta es la razón profunda por la que XState se dibuja tan bien, se testea tan fácil y se comporta de forma tan predecible: porque en su base no hay un proceso oscuro guardando estado, sino un valor transparente que una capa fina de ejecución se limita a interpretar. Lógica como dato, ejecución como servicio: separarlas es el acto que lo ordena todo.
- Crea dos actores de una misma máquina, avanza uno con
sendy comprueba congetSnapshot().valueque el otro no cambió. - Sin crear ningún actor, calcula el estado tras tres eventos usando
initialTransitionytransitionencadenados. - Escribe un test que afirme el estado resultante de una secuencia de eventos llamando solo a
transition, sin runtime alguno. - Suscríbete a un actor con
subscribe, envíale eventos y observa que cada cambio notifica; luego detenlo constopy comprueba que deja de responder. - Persiste el estado con
getPersistedSnapshot, crea un actor nuevo restaurándolo con la opciónsnapshot, y argumenta por qué serializas el estado pero no el actor.