wandres.dev
CUÁNDO UNA MÁQUINA · y cuándo no

Empezar pequeño: la FSM a mano

Reconocer que un problema tiene forma de máquina no obliga a importar XState de inmediato. Una FSM manual —una tabla de transiciones y una función de transición pura— entrega el ochenta por ciento del valor en veinte líneas sin dependencias. Esta lección construye esa máquina mínima, le añade exhaustividad por tipos y cableado a React, delimita con precisión qué te da gratis y qué no, y fija el umbral de adopción de la librería: no la aspiración, sino el dolor concreto que la justifica.

⏱ 18 min

Hay un salto injustificado que casi todo el mundo da: del “esto es una máquina” al “instalo XState”. Entre ambos hay un territorio enorme y fértil que la prisa se salta —la máquina escrita a mano—, y recorrerlo primero no es una concesión para principiantes, sino la disciplina correcta. Una máquina de estado finita es, en su núcleo, una tabla de transiciones y una función pura que la consulta; eso cabe en veinte líneas de TypeScript, no tiene dependencias y entrega la mayor parte del valor de una librería. Empezar ahí te enseña la forma de tu problema sin comprometerte con un ecosistema, y te deja adoptar la librería más tarde con datos en la mano en vez de con entusiasmo.

🎯 Al terminar esta lección sabrás
  • Construir una FSM a mano con una tabla de transiciones y una función de transición pura.
  • Añadirle exhaustividad por tipos y cablearla a React sin ninguna dependencia.
  • Delimitar qué garantías da gratis la versión manual y cuáles no.
  • Identificar el umbral de adopción y el camino de migración hacia XState.

La FSM de veinte líneas: una tabla de transiciones

Una máquina finita es una función (estado, evento) -> estado. Escrita a mano, esa función es una tabla anidada: para cada estado, qué eventos acepta y a qué estado llevan. Todo lo demás es decoración. Aquí está, entera, para un flujo de carga con reintento:

type Estado = 'inactivo' | 'cargando' | 'exito' | 'error'
type Evento = 'PEDIR' | 'OK' | 'FALLO' | 'REINTENTAR'

const transiciones: Record<Estado, Partial<Record<Evento, Estado>>> = {
  inactivo: { PEDIR: 'cargando' },
  cargando: { OK: 'exito', FALLO: 'error' },
  exito:    {},                                  // estado final, sin salidas
  error:    { REINTENTAR: 'cargando' },
}

// Funcion de transicion pura: sin efectos, testeable en aislamiento.
function transitar(estado: Estado, evento: Evento): Estado {
  return transiciones[estado][evento] ?? estado  // evento ilegal: no cambia
}

Eso es una FSM completa. transitar('inactivo', 'OK') devuelve 'inactivo' sin drama, porque esa arista no existe: los eventos ilegales se ignoran, igual que en XState, pero sin importar nada. La tabla es la especificación del protocolo, legible de un vistazo, y la función que la consulta es pura: se prueba sin montar un mundo, sin renderizar nada, sin mocks.

Falta una garantía que la librería te daría y que aquí conviene añadir a mano, porque cuesta una línea: la exhaustividad. Quieres que el compilador te avise si un día agregas un estado y olvidas manejarlo en algún sitio. El truco es una función que solo acepta el tipo never, el tipo vacío al que se reduce un switch cuando ya cubrió todos los casos.

function nunca(x: never): never {
  throw new Error('estado no manejado: ' + String(x))
}

function etiqueta(estado: Estado): string {
  switch (estado) {
    case 'inactivo': return 'Listo'
    case 'cargando': return 'Cargando'
    case 'exito':    return 'Hecho'
    case 'error':    return 'Fallo'
    default:         return nunca(estado)  // error de compilacion si falta un caso
  }
}

El día que añadas 'reintentando' al tipo Estado, el default recibirá un valor que ya no es never y la compilación fallará hasta que agregues su rama. Esa red de seguridad —la misma que da una máquina de librería— la tienes con una función auxiliar, sin dependencias, y te protege justo del error más caro: el estado que nadie contempló.

💡
La máquina y su cableado son cosas distintas

Fíjate en que transitar no sabe nada de React, de un store ni del DOM: es una función pura sobre dos tipos. Esa separación es deliberada y es media victoria. Conectarla a React es un useReducer con esa función dentro; conectarla a Zustand es meterla en una action; probarla es llamarla con valores. La lógica de estados vive en un sitio y su cableado en otro, de modo que puedes cambiar el segundo sin tocar el primero. Cuando esa separación se respeta, migrar a XState —o salir de él— es un cambio de cableado, no una reescritura.

Qué te da gratis y qué no

La versión manual no es un juguete: cubre el corazón de lo que hace valiosa a una máquina. Pero tiene un techo claro, y conocerlo es lo que convierte la decisión de adoptar en ingeniería y no en fe.

Gratis a mano

Rechazo de transiciones ilegales, exhaustividad por tipos, función pura y testeable, cero dependencias, y una tabla que se lee como la especificación del dominio.

⚠️

Caro a mano

Jerarquía y estados paralelos, el modelo de actores con invoke de promesas, guards y delays ergonómicos, y el reajuste al crecer el grafo. Se puede, pero lo reinventas.

🔍

Casi imposible a mano

El visualizador que dibuja el statechart, la inspección en vivo del actor, y las devtools que reproducen la secuencia de eventos. Aquí la librería no tiene rival.

Conectar la máquina a la UI es trivial precisamente por su pureza. En React, useReducer acepta una función (estado, evento) -> estado, que es exactamente la firma de transitar:

// La misma maquina, cableada a React sin ninguna dependencia.
function useCarga() {
  const [estado, enviar] = useReducer(transitar, 'inactivo')
  return { estado, enviar }  // enviar('PEDIR'), enviar('OK')...
}

Ese cableado de tres líneas esconde la ventaja más subestimada de empezar a mano: la testabilidad. Como transitar es pura, tu suite de tests no necesita renderizar componentes ni simular clics; comprueba el protocolo llamando a la función con estados y eventos y comparando el resultado. Los caminos imposibles se verifican en milisegundos y sin frameworks, algo que una máquina enterrada en la UI vuelve mucho más engorroso de aislar.

El patrón a vigilar es este: mientras tu tabla de transiciones crece en anchura —más estados, más eventos planos— la versión manual escala bien y no necesitas nada más. El problema aparece cuando crece en profundidad y en concurrencia: cuando empiezas a necesitar subestados dentro de estados, regiones que corren en paralelo, o coordinar procesos asíncronos con cancelación. Ahí la tabla anidada se vuelve una maraña, y cada línea que añades es una pieza de XState reimplementada peor. Ese es el síntoma, no la aspiración.

El umbral de adopción: el dolor que compra la librería

La regla es incómoda de tan simple: adopta la librería cuando el dolor lo justifique, no cuando la aspiración lo sugiera. La diferencia es todo. La aspiración dice “algún día esto podría tener regiones paralelas, mejor uso XState ya”. El dolor dice “llevo tres funciones reimplementando invoke a mano y se me escapan los errores de las promesas”. Solo el segundo es una razón.

flowchart TD
A[el problema es una maquina] --> B[FSM a mano de 20 lineas]
B --> C{aparece dolor concreto?}
C -->|no| D[quedate a mano]
C -->|jerarquia o paralelo| E[adopta XState]
C -->|invoke async con cancelacion| E
C -->|necesitas el visualizador| E
C -->|solo aspiracion futura| D
style B fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#cba6f7,color:#11111b

El ecosistema de 2026 ofrece además peldaños intermedios entre la tabla a mano y el statechart completo, y conviene conocerlos para no saltar de golpe. @xstate/store da un store diminuto con transiciones tipadas sin el peso del actor model, ideal cuando quieres la disciplina de eventos pero no la jerarquía. Zag.js empaqueta máquinas ya hechas para componentes de UI —menús, diálogos, tabs— y te ahorra modelar protocolos de interacción resueltos mil veces. Robot ofrece FSM en menos de un kilobyte para cuando la tabla a mano se queda justa pero XState sobra. Y useReducer con una función de transición como la de arriba sigue siendo, para una FSM plana, la opción más honesta en React. La escalera no termina en XState: termina en la herramienta más pequeña que resuelve tu dolor real.

Conviene notar que los dos errores de tiempo no son simétricos. Adoptar XState demasiado pronto te cobra la curva y la ceremonia durante meses sobre un problema que no las necesitaba; adoptarlo demasiado tarde te cuesta una migración de una tarde, porque tu tabla a mano ya tenía el diseño hecho. Cuando dudes del momento, la penalización menor está en esperar: llegar tarde a la librería es mucho más barato que llegar pronto.

Migrar sin dolor: de la tabla a XState

La razón última para empezar a mano es que migrar después es barato. Tu tabla de transiciones se traduce casi carácter por carácter a la configuración de XState, porque ambas expresan lo mismo —estados y aristas— con sintaxis distinta.

// La tabla a mano se traduce casi 1 a 1 a XState v5.
createMachine({
  initial: 'inactivo',
  states: {
    inactivo: { on: { PEDIR: 'cargando' } },
    cargando: { on: { OK: 'exito', FALLO: 'error' } },
    exito:    {},
    error:    { on: { REINTENTAR: 'cargando' } },
  },
})

Comparadas lado a lado, la tabla y la config son el mismo grafo. Lo que XState añade encima no es el grafo, sino los servicios que lo rodean: invoke para procesos asíncronos con su ciclo de vida, assign para el estado extendido, guards y delays declarativos, el inspector. Migras el día que necesitas alguno de esos servicios, y la migración no toca tu modelo del problema porque ese modelo ya estaba resuelto en la tabla. Quien empezó a mano llega a XState con el diseño hecho; solo cambia el motor que lo ejecuta.

Escribir la máquina a mano es el diseño, no el borrador

Existe la idea de que la FSM manual es un prototipo desechable, un paso previo que tiraremos cuando llegue la herramienta de verdad. Es al revés. Escribir la tabla de transiciones a mano es el acto de diseño más importante de todo el proceso, y la librería —cuando llega— solo la ejecuta con más servicios alrededor. Cuando te obligas a rellenar Record<Estado, Partial<Record<Evento, Estado>>> estás tomando, de forma explícita y una por una, cada decisión que define el problema: qué estados existen, qué eventos acepta cada uno, a dónde llevan y, sobre todo, qué transiciones deliberadamente no existen. Esa tabla de huecos vacíos —los eventos que un estado no maneja— es la parte más valiosa y la que ninguna librería puede pensar por ti; XState te la ejecutará, pero eres tú quien decide qué prohibir. Por eso empezar pequeño no es cobardía ante la herramienta ni ahorro de dependencias: es rehusar delegar el diseño en la sintaxis de una librería que aún no entiendes del todo. Quien empieza a mano llega a XState sabiendo exactamente qué le pide y por qué, y lo adopta el día que un dolor concreto —jerarquía, actores, el visualizador— aparece y le compra algo que su tabla ya no puede dar barato. Quien empieza por XState corre el riesgo inverso: dejar que la forma de la librería le dicte la forma de un problema que nunca modeló por su cuenta. La máquina a mano es el pensamiento; la librería es la infraestructura. Nunca confundas el orden.

⚔️ Modela a mano antes de importar nada
  1. Toma un flujo real y escribe su FSM a mano: el tipo Estado, el tipo Evento, la tabla transiciones y la función transitar pura. Que quepa en veinte líneas.
  2. Añade el chequeo de exhaustividad con la función nunca y comprueba que el compilador te obliga a manejar un estado nuevo cuando lo agregas.
  3. Escribe tres tests de la función de transición sin renderizar nada: una transición legal, un evento ilegal ignorado, y un camino de dos pasos.
  4. Cablea la máquina a React con useReducer y a un store con una action. Observa que el protocolo no cambió: solo cambió el cableado.
  5. Traduce tu tabla a una config de XState y compáralas lado a lado. Anota qué servicios —invoke, assign, guards— necesitarías de verdad y cuáles no.
  6. Decide si lo que te falta es dolor presente o aspiración futura. Solo migra por lo primero.