Conectar la máquina a React: useMachine y useActor
Una máquina de XState v5 es lógica de actor pura y vive fuera de React. El paquete @xstate/react tiende el puente: useMachine instancia un actor a partir de la máquina, lo arranca al montar el componente, suscribe React a sus snapshots y lo detiene al desmontar. Esta lección disecciona ese puente —el snapshot inmutable que describe la situación actual, el send que es la única puerta para mutar y el actorRef que expone el actor vivo con identidad estable entre renders— y separa useMachine de useActor para cuando la lógica no llega a ser una máquina completa.
Una máquina de estado no sabe que React existe. La defines en un módulo puro, la pruebas sin montar un solo componente y podrías ejecutarla en un servidor. El trabajo del adaptador @xstate/react es humilde pero crítico: instanciar un actor a partir de esa lógica, arrancarlo cuando el componente se monta, mantenerlo vivo entre renders, suscribir React a cada snapshot que emite y detenerlo al desmontar. Interiorizar que el hook no contiene el estado —que solo observa un actor que vive a su lado— es la llave mental de todo el nivel. React deja de ser el dueño del estado y pasa a ser una proyección del actor.
- Entender que
useMachineinstancia y arranca un actor ligado al ciclo de vida del componente. - Leer el snapshot —
state.value,state.context,state.status— y despachar eventos consend. - Distinguir
useMachinedeuseActory reconocer cuándo la lógica no es una máquina. - Ver el hook como una suscripción: React proyecta al actor, no lo posee.
Del logic al actor: qué hace de verdad el hook
XState v5 separa dos conceptos que en v4 se confundían: la actor logic y el actor. setup(...).createMachine(...) devuelve lógica: una descripción inerte, sin estado propio, comparable a una clase sin instanciar. Un actor es la instancia viva, con su snapshot actual y su cola de eventos. useMachine(maquina) toma la lógica, llama internamente a createActor, la arranca con actor.start() en el montaje, se suscribe y llama a actor.stop() en la limpieza. La identidad del actor es estable entre renders: no se recrea en cada pintado. Lo único que cambia de un render a otro es el snapshot que el hook lee.
import { setup, fromPromise, assign } from "xstate";
export const maquinaBusqueda = setup({
types: {
context: {} as { resultados: string[]; error: string | null },
events: {} as { type: "BUSCAR" } | { type: "REINTENTAR" },
},
actors: {
pedirResultados: fromPromise(async () => {
const r = await fetch("/api/buscar");
return (await r.json()) as string[];
}),
},
}).createMachine({
id: "busqueda",
initial: "inactivo",
context: { resultados: [], error: null },
states: {
inactivo: { on: { BUSCAR: "cargando" } },
cargando: {
invoke: {
src: "pedirResultados",
onDone: { target: "listo", actions: assign({ resultados: ({ event }) => event.output }) },
onError: { target: "fallo", actions: assign({ error: ({ event }) => String(event.error) }) },
},
},
listo: { type: "final" },
fallo: { on: { REINTENTAR: "cargando" } },
},
});
flowchart LR L[logic createMachine] --> H[useMachine] H --> A[actor start] A --> S[snapshot inmutable] S --> R[React render] R -->|send evento| A A -->|unmount| Z[actor stop]
Entre dos renders, useMachine no recrea el actor: su identidad —y la de send y actorRef— se mantiene estable, igual que el dispatch de useReducer. Lo único que cambia de un render a otro es el snapshot que devuelve la tupla. Por eso puedes pasar send a un hijo memoizado sin provocar renders en cascada, y por eso actorRef sirve como identidad estable para las suscripciones de grano fino de la lección de rendimiento.
El snapshot y el send
useMachine devuelve una tupla [state, send, actorRef]. El primer elemento es el snapshot: un objeto inmutable que describe la situación actual y expone state.value —el estado finito—, state.context —el estado extendido— y state.status —active, done o error—, además de los métodos state.matches, state.can y state.hasTag. El segundo, send, es la única puerta para mutar: empujas un objeto de evento y el actor decide qué hacer. El tercero, actorRef, es el actor vivo; lo pasarás a useSelector o a componentes hijos en las lecciones siguientes.
import { useMachine } from "@xstate/react";
import { maquinaBusqueda } from "./maquina";
export function Buscador() {
const [state, send, actorRef] = useMachine(maquinaBusqueda);
return (
<section>
<p>Estado actual: {String(state.value)}</p>
<button onClick={() => send({ type: "BUSCAR" })}>Buscar</button>
{state.matches("listo") && (
<ul>{state.context.resultados.map((r) => <li key={r}>{r}</li>)}</ul>
)}
</section>
);
}
state.value
El estado finito actual: un string como cargando o un objeto para estados anidados. Es lo que decide qué pinta la vista.
state.context
El estado extendido: los datos —resultados, contador, error— que no caben en el nombre de un estado y viven aparte de él.
matches, can, hasTag
Métodos de consulta sobre el snapshot: en qué estado estás, si un evento tendría efecto y si el estado lleva cierta etiqueta.
Nunca escribes sobre state. No existe un setState aquí. La única forma de que state.value cambie es que envíes un evento con send y el actor transicione, produciendo un snapshot nuevo. React se entera por la suscripción y vuelve a renderizar. Este flujo de un solo sentido es exactamente la disciplina de mutación que una máquina impone: la vista lee, el actor decide.
useActor: cuando la lógica no es una máquina
useMachine está especializado en máquinas. useActor es más general: acepta cualquier actor logic —un actor de promesa creado con fromPromise, un reductor de transición con fromTransition, un actor de callback o una máquina completa—. Ambos devuelven la misma forma de tupla [snapshot, send, actorRef], de modo que el patrón de consumo es idéntico. Usa useActor cuando la unidad de lógica es más simple que un statechart —una sola petición asíncrona modelada como actor de promesa— y aún así quieres el enlace con el ciclo de vida y la re-renderización automática.
import { fromPromise } from "xstate";
import { useActor } from "@xstate/react";
const cargaPerfil = fromPromise(async () => {
const r = await fetch("/api/perfil");
return (await r.json()) as { nombre: string };
});
export function Perfil() {
const [snapshot] = useActor(cargaPerfil);
if (snapshot.status === "done") return <h2>{snapshot.output.nombre}</h2>;
if (snapshot.status === "error") return <p>Error al cargar</p>;
return <p>Cargando perfil...</p>;
}
La lección de diseño es que el adaptador no distingue entre una máquina de veinte estados y una promesa: ambas son actores, y un actor siempre ofrece la misma interfaz —un snapshot que observar y un buzón al que enviar—. Empieza pequeño con useActor y una promesa; migra a useMachine y un statechart cuando la lógica gane transiciones sin renombrar nada del lado de la vista. Esa continuidad —de la promesa trivial al statechart complejo por el mismo hueco de la tupla— es lo que hace que adoptar máquinas sea gradual en lugar de un salto brusco.
El error de modelo mental más caro al llegar de useState es creer que el hook guarda el estado. No lo guarda. El estado vive en un actor —una entidad con identidad propia, buzón y ciclo de vida— que existiría igual sin React. useMachine es una suscripción: cuando el actor emite un snapshot, el hook fuerza un render, y nada más. De ahí se siguen tres consecuencias que definen todo el nivel. Primera: la vista es una función pura del snapshot, porque no tiene otra fuente de verdad que consultar. Segunda: la vista nunca decide una transición; solo empuja hechos con send y el actor, dueño de sus reglas, resuelve. Tercera: el mismo actor puede ser observado por muchos componentes a la vez sin duplicar estado, porque la fuente es una sola y externa. Cuando dejas de preguntarte “¿dónde guardo este dato en el componente?” y empiezas a preguntarte “¿qué actor lo posee y cómo lo proyecto?”, has cruzado la frontera que separa manejar estado de arquitecturarlo. El resto de la UI —renderizar, enviar eventos, optimizar renders— son técnicas para pulir esa proyección, pero la proyección nunca es el original.
- Monta la
maquinaBusquedaconuseMachiney muestrastate.valueystate.statusen pantalla; observa cómo cambian al pulsar el botón. - Añade un
console.logen el cuerpo del componente y confirma que solo re-renderiza cuando el actor transiciona, no en cada tecla. - Sustituye la carga por un actor de promesa con
fromPromisey consúmelo conuseActor; comprueba que la vista no cambia de forma. - Extrae el
actorRefde la tupla y pásalo a un componente hijo por props, sin lógica todavía: lo suscribirás en la lección de rendimiento. - Desmonta el componente mientras la petición está en vuelo y verifica que el actor se detiene sin fugas ni avisos en consola.
- Comprueba la estabilidad referencial: memoiza un hijo que reciba
sendy confirma que no se re-renderiza cuando el padre sí lo hace.