wandres.dev
ALTERNATIVAS A XSTATE · Robot, Zag, a mano

Robot y las librerías ligeras: la FSM minimalista

Entre las veinte líneas a mano y el statechart completo vive una franja poblada de librerías de un kilobyte que resuelven exactamente el caso plano. Esta lección disecciona Robot como representante canónico de la escuela funcional —máquinas compuestas con funciones en vez de descritas con objetos— y la contrasta con los almacenes de eventos tipados de la familia XState. El eje que de verdad separa a estas herramientas no es el peso ni la potencia, sino una propiedad que casi ninguna comparativa menciona: si la definición de la máquina sigue siendo un dato que otra herramienta pueda leer.

⏱ 17 min

Hay una franja del espacio de diseño que las discusiones de este ecosistema suelen saltarse: el tramo donde tu máquina ya es demasiado grande para el intérprete casero pero sigue siendo plana, sin jerarquía ni regiones ni actores. Ese tramo lo ocupan librerías de un kilobyte escaso que hacen una promesa muy concreta —transiciones legales, guardas, reductores de contexto y suscripción— y que renuncian deliberadamente al resto. Robot es la más pura de todas y la mejor para estudiarlas, porque su decisión de diseño fundacional, componer la máquina con funciones en vez de describirla con un objeto, tiene consecuencias que van mucho más allá del estilo y que explican por qué pesa lo que pesa y por qué no puede darte ciertas cosas jamás.

🎯 Al terminar esta lección sabrás
  • Leer y escribir una máquina en la escuela funcional de composición que representa Robot.
  • Distinguir una FSM minimalista de un almacén de eventos tipados sin legalidad declarada.
  • Entender qué propiedad se pierde cuando la definición deja de ser un dato inspeccionable.
  • Situar cada librería ligera en la franja concreta de problemas donde es la opción correcta.

La escuela funcional: componer en vez de describir

XState describe la máquina como un objeto de configuración anidado. Robot la compone aplicando funciones: state envuelve transiciones, transition envuelve guardas y reductores, y el conjunto se pasa a createMachine. Cada pieza es una importación independiente, y ahí está la clave de su tamaño: lo que no usas no entra en el paquete final porque el empaquetador puede eliminarlo por análisis estático.

import { createMachine, state, transition, guard, reduce, interpret } from 'robot3'

type Ctx = { intentos: number; datos: string[]; error: string | null }

const maquina = createMachine(
  {
    inactivo: state(transition('PEDIR', 'cargando')),
    cargando: state(
      transition('RESUELTO', 'listo', reduce((ctx: Ctx, ev: any) => ({ ...ctx, datos: ev.datos }))),
      transition('FALLADO', 'fallo', reduce((ctx: Ctx, ev: any) => ({ ...ctx, error: ev.error }))),
    ),
    listo: state(transition('PEDIR', 'cargando')),
    fallo: state(
      transition('REINTENTAR', 'cargando',
        guard((ctx: Ctx) => ctx.intentos < 3),
        reduce((ctx: Ctx) => ({ ...ctx, intentos: ctx.intentos + 1 })),
      ),
    ),
  },
  (): Ctx => ({ intentos: 0, datos: [], error: null }),
)

const servicio = interpret(maquina, (s) => pintar(s.machine.current, s.context))
servicio.send('PEDIR')

Tres detalles merecen atención por lo que revelan del modelo. El primero es que guard y reduce son argumentos de la transición y se aplican en orden, de manera que la condición decide y el reductor transforma el contexto durante el paso: es semántica de Mealy pura, el efecto pertenece a la arista y no al nodo. El segundo es que interpret recibe una función que se ejecuta en cada cambio, sin suscripciones múltiples ni selectores; la integración con la interfaz es asunto tuyo o del adaptador que uses. El tercero, y el más importante, es que la máquina resultante no es un objeto que puedas recorrer con sentido: es un grafo de cierres.

La escuela funcional cubre además dos piezas que el intérprete casero de la primera lección no traía: la transición inmediata, que evalúa guardas al entrar en un estado y sale de él sin esperar ningún evento, y la invocación de una promesa con sus dos desenlaces ya cableados.

import { immediate, invoke } from 'robot3'

const conCarga = createMachine({
  inactivo: state(transition('PEDIR', 'cargando')),
  cargando: invoke(
    (ctx: Ctx) => fetch('/api/items').then((r) => r.json()),
    transition('done', 'evaluando', reduce((ctx: Ctx, ev: any) => ({ ...ctx, datos: ev.data }))),
    transition('error', 'fallo'),
  ),
  evaluando: state(
    immediate('vacio', guard((ctx: Ctx) => ctx.datos.length === 0)),  // transicion transitoria
    immediate('listo'),                                                // caso por defecto
  ),
  vacio: state(),
  listo: state(),
  fallo: state(transition('REINTENTAR', 'cargando')),
})

El estado evaluando es un estado transitorio en el sentido estricto que estudiaste en la semántica formal: nadie lo observa porque la máquina lo atraviesa dentro del mismo paso de ejecución, y su única función es alojar la decisión entre dos destinos según el contexto recién obtenido. Que una librería de un kilobyte incluya esa primitiva dice mucho sobre qué es realmente esencial en una FSM plana y qué es acomodo.

ℹ️
El árbol se sacude porque no hay intérprete universal

El motivo real de que estas librerías pesen un kilobyte no es que su código sea más astuto, sino que reparten la funcionalidad en funciones importables por separado en lugar de concentrarla en un intérprete que debe saber ejecutar cualquier configuración posible. Un motor completo tiene que llevar dentro el algoritmo de resolución de transiciones jerárquicas aunque tu máquina sea plana, porque no puede saber de antemano qué le vas a pasar. La composición funcional traslada esa decisión al momento de escribir el código, y el empaquetador se lleva el mérito.

Lo que se paga con la ligereza

Aquí está el argumento central de la lección, y no aparece en las tablas de peso que circulan por ahí. Cuando la máquina se compone con funciones, su definición deja de ser un dato y pasa a ser código ejecutable. Todo lo que dependía de que la definición fuese inspeccionable desaparece de golpe: no hay diagrama generado, no hay inspector, no hay exportación a un formato común, no hay recorrido exhaustivo del grafo para generar pruebas basadas en modelo, no hay comparación estructural entre dos versiones en una revisión de código.

🪶

Robot y la escuela funcional

Alrededor de un kilobyte. Estados planos, guardas, reductores y transiciones inmediatas. Semántica de Mealy explícita en cada arista.

📦

Almacenes de eventos tipados

@xstate/store da eventos tipados, contexto y selectores con muy poco peso, pero no declara qué transiciones son legales.

🧊

Definición como dato

Solo si la máquina es una estructura inerte puede una herramienta externa leerla. Es la condición del diagrama que es el código.

⚰️

Cuidado con lo abandonado

El antiguo @xstate/fsm está descontinuado. En esta franja la salud del mantenimiento pesa tanto como los kilobytes.

El contraste con la familia de almacenes tipados aclara la geografía de la franja. Un almacén como @xstate/store te da lo que un reducer bien tipado te daba, con menos ceremonia y con selectores para leer sin renderizar de más, pero su unidad de decisión sigue siendo el evento y no el par estado-evento.

import { createStore } from '@xstate/store'

const almacen = createStore({
  context: { fase: 'inactivo', datos: [] as string[] },
  on: {
    PEDIR: (ctx) => ({ ...ctx, fase: 'cargando' }),          // nadie comprueba desde donde
    RESUELTO: (ctx, ev: { datos: string[] }) => ({ ...ctx, fase: 'listo', datos: ev.datos }),
  },
})

almacen.subscribe((s) => pintar(s.context))
almacen.send({ type: 'PEDIR' })

La diferencia con Robot es de naturaleza, no de grado: en el almacén, PEDIR se acepta siempre y la legalidad depende de que tú la compruebes dentro de la función; en Robot, PEDIR desde cargando sencillamente no existe y el evento se ignora sin que escribas nada. Si lo que te faltaba era tipado y ergonomía, el almacén es suficiente y más barato; si lo que te faltaba era prohibir transiciones, el almacén no resuelve tu problema por muy poco que pese.

Propiedad Tabla a mano Robot Almacén tipado XState v5
Peso añadido ninguno mínimo mínimo notable
Transiciones ilegales prohibidas no
Definición inspeccionable no parcial
Jerarquía y regiones paralelas no no no
Actores con ciclo de vida no invocación básica no
Visualización y depuración ninguna ninguna inspector básico estudio e inspector
flowchart TD
A[tu maquina es plana?] -->|no| B[XState v5]
A -->|si| C[necesitas prohibir transiciones?]
C -->|no| D[almacen de eventos tipados]
C -->|si| E[alguien debe ver el grafo fuera del codigo?]
E -->|si| F[tabla como dato o XState v5]
E -->|no| G[Robot o libreria funcional equivalente]
style D fill:#89dceb,color:#11111b
style G fill:#a6e3a1,color:#11111b
style B fill:#cba6f7,color:#11111b
💡
La pregunta que ordena toda la franja

No preguntes cuál pesa menos, sino quién necesita leer tu máquina además del intérprete. Si la respuesta es nadie, la composición funcional es óptima y el kilobyte está bien gastado. Si la respuesta incluye a una persona de producto, a un generador de pruebas o a ti mismo dentro de un año frente a un grafo de dieciséis estados, entonces necesitas que la definición sea dato, y esa necesidad manda por encima del peso.

Cuándo la franja ligera es la respuesta correcta

Hay un perfil de problema donde estas librerías ganan a las dos opciones vecinas, y conviene reconocerlo con precisión en vez de por sensación. Gana a la tabla a mano cuando la máquina tiene suficientes transiciones como para que el intérprete casero empiece a acumular casos especiales, cuando quieres guardas y reductores con una forma común en lugar de inventada por cada quien, y cuando el equipo agradece un vocabulario compartido que no haya que documentar. Gana al motor completo cuando el problema es genuinamente plano y el presupuesto de descarga es real y ajustado: componentes embebidos en páginas de terceros, entornos con conectividad pobre, bibliotecas que no quieren imponer una dependencia pesada a quien las consume.

Pierde, en cambio, en dos situaciones que se confunden a menudo con la suya. Pierde cuando el problema no era plano y alguien lo aplanó a base de nombres compuestos, porque entonces la ligereza compra un modelo peor. Y pierde cuando el equipo tiene ya XState en el paquete por otra máquina, porque el coste marginal de la segunda máquina en un motor que ya cargas es cero, mientras que añadir una librería distinta introduce un segundo vocabulario y una segunda superficie de mantenimiento para ahorrar bytes que ya estabas gastando.

⚠️
El coste de un ecosistema pequeño

Estas librerías son excelentes y son frágiles por razones que no tienen que ver con su código. Un proyecto sostenido por una o dos personas puede quedarse quieto durante años sin que eso sea un problema —una FSM plana es un dominio cerrado y no necesita evolucionar— pero el día que cambia algo del entorno, no hay a quién recurrir. Contabiliza esa fragilidad en la decisión igual que contabilizas los kilobytes, y prefiere siempre aquellas cuya API es tan pequeña que podrías reimplementarla en una tarde. La reversibilidad barata es la mejor defensa contra el riesgo de mantenimiento.

Lo que compras con el kilobyte no es potencia: es opacidad

La comparativa habitual entre motores de máquinas se hace sobre dos ejes, peso y capacidades, y ambos son secundarios frente a uno que casi nunca se dibuja: la reificación del modelo. Una máquina compuesta con funciones y una máquina descrita con un objeto pueden ser equivalentes en lo que ejecutan y ser radicalmente distintas en lo que permiten hacer al resto del sistema, porque la primera existe únicamente mientras corre y la segunda existe también como cosa, como estructura que se puede leer, transformar, dibujar, exportar, recorrer y verificar sin ejecutarla. Esa distinción es la misma que separa a un programa de su árbol sintáctico, y tiene la misma consecuencia: sobre la cosa se pueden construir herramientas, sobre la ejecución solo se puede observar. Cuando el ecosistema promete que el diagrama es el código, lo que en realidad está prometiendo es que el modelo está reificado; ninguna librería que componga con cierres puede cumplir esa promesa por muy bien escrita que esté, y ninguna cantidad de kilobytes ahorrados la compensa el día que necesitas mirar el grafo entero. Por eso la elección en esta franja no se decide midiendo, se decide preguntándose qué va a querer hacer alguien con tu máquina además de ejecutarla; y si la respuesta honesta hoy es nada, elige lo ligero sin remordimiento, sabiendo que has vendido una opción futura a cambio de un beneficio presente, que es una operación legítima siempre que sepas que la estás haciendo.

⚔️ Explora la franja ligera
  1. Reescribe en Robot la máquina de petición con reintento limitado y comprueba que la guarda impide el cuarto intento sin que tú escribas ningún condicional fuera.
  2. Intenta obtener la lista de todos los estados y todas las transiciones de esa máquina desde fuera, sin ejecutarla. Documenta hasta dónde llegas.
  3. Modela el mismo problema con un almacén de eventos tipados y comprueba qué ocurre al enviar un evento inaplicable. Compara con el comportamiento de Robot.
  4. Mide el peso real de las tres opciones en tu propio empaquetador, con tu configuración y tu máquina, no con las cifras publicadas.
  5. Elige una máquina de tu proyecto y responde por escrito quién más necesita leerla. Deja que esa respuesta, y no el peso, decida la herramienta.
  6. Escribe en cinco líneas el plan de salida de la librería ligera que elijas: qué habría que cambiar para migrar a un motor completo si el problema deja de ser plano.