wandres.dev
MÁQUINAS FINITAS · estados y transiciones

Una FSM en código, sin librería

Una máquina de estados finita no necesita ninguna dependencia: se implementa a mano con un switch sobre el estado actual o con un objeto de transiciones que consulta el par estado-evento. Con menos de treinta líneas obtienes estados, transiciones, acciones de entrada y salida, y hasta guardas. Esta lección construye ambas variantes desde cero, muestra que el useReducer de React y todo reducer de Redux ya son máquinas de estado, y traza con precisión la frontera: qué puedes hacer a mano y en qué punto exacto conviene delegar en XState v5, el paquete @xstate/store o Zag.js.

⏱ 17 min

Existe la superstición de que trabajar con máquinas de estado exige adoptar una librería pesada. Es falso, y creerlo te aleja del modelo justo cuando más te conviene entenderlo. Una FSM es una función pura de estado y evento a estado: cabe en un switch, cabe en un objeto, cabe en la cabeza. Antes de instalar nada, quiero que la escribas a mano hasta que la mecánica te resulte transparente, porque solo así sabrás qué te está dando de verdad una librería el día que la necesites —y qué te estaba dando gratis el lenguaje desde el principio—.

🎯 Al terminar esta lección sabrás
  • Implementar una FSM con un switch sobre el estado actual, sin ninguna dependencia.
  • Reescribirla como objeto de transiciones dirigido por datos, con acciones de entrada.
  • Reconocer que useReducer y todo reducer de Redux son máquinas de estado disfrazadas.
  • Trazar la frontera exacta entre lo que se hace a mano y lo que justifica un motor como XState v5.

El switch: la FSM más honesta

La forma más directa de escribir δ es un switch anidado: por fuera ramificas según el estado, por dentro según el evento. Es verbosa, pero no esconde nada: cada transición es una línea que puedes leer y verificar.

type Estado = "idle" | "loading" | "success" | "error"
type Evento = { type: "FETCH" } | { type: "RESOLVE" } | { type: "REJECT" } | { type: "RETRY" }

function transicion(estado: Estado, evento: Evento): Estado {
  switch (estado) {
    case "idle":
      return evento.type === "FETCH" ? "loading" : estado
    case "loading":
      if (evento.type === "RESOLVE") return "success"
      if (evento.type === "REJECT") return "error"
      return estado
    case "error":
      return evento.type === "RETRY" ? "loading" : estado
    case "success":
      return estado   // estado terminal: ningun evento lo mueve
  }
}

Cada return estado es una celda vacía de la tabla hecha código: el evento inaplicable devuelve el estado sin cambios, la política de ignorado silencioso del nivel anterior. Cero dependencias, cero magia, y el compilador exige que trates cada estado en el switch. Para una máquina pequeña, esto es difícil de mejorar.

El objeto de transiciones: datos, no código

Cuando la máquina crece, el switch se vuelve ruidoso. La alternativa es dirigir por datos: la tabla como objeto, y una función diminuta que la consulta. La lógica de transición deja de ser código y pasa a ser una estructura que puedes serializar, visualizar o generar.

const maquina = {
  inicial: "idle" as const,
  estados: {
    idle:    { on: { FETCH: "loading" } },
    loading: { on: { RESOLVE: "success", REJECT: "error" }, entry: () => log("cargando...") },
    success: { on: {} },
    error:   { on: { RETRY: "loading" } },
  },
}

function enviar(estado: string, tipo: string) {
  const destino = maquina.estados[estado]?.on?.[tipo] ?? estado   // sin transicion = quieto
  if (destino !== estado) maquina.estados[destino]?.entry?.()      // accion de entrada estilo Moore
  return destino
}

Con quince líneas ya tienes estados, transiciones y acciones de entrada al estilo Moore —el efecto se dispara al ENTRAR en el estado, no en la transición—. Añadir una guarda es trivial: en lugar de que la celda sea un string, que sea un objeto con destino y una función cond que decide si la transición procede. Con eso cubres el noventa por ciento de las máquinas que una interfaz necesita.

ℹ️
Ya escribes máquinas de estado sin saberlo

El useReducer de React es, literalmente, una función de transición: (estado, accion) => nuevoEstado. Si tu estado es una unión discriminada por status y el reducer ramifica sobre él, has escrito una FSM sin nombrarla. Lo mismo vale para cualquier reducer de Redux o Zustand cuyo estado tenga un campo de fase. La diferencia entre “un reducer con un if sobre status” y “una máquina de estados” no es técnica, es de disciplina: la máquina te obliga a declarar transiciones legales, mientras que el reducer suelto te deja escribir cualquier salto. Verlo así desmitifica el modelo: no es una tecnología nueva que adoptar, es un rigor que ya casi tenías.

Una función de transición pura no tiene vida propia: para animarla necesitas un envoltorio mínimo que guarde el estado actual y avise a quien escuche. Ese envoltorio son otras diez líneas, y con ellas la máquina se convierte en un store observable completo.

function crearMaquina(inicial: Estado) {
  let estado = inicial
  const oyentes = new Set<(e: Estado) => void>()
  return {
    get: () => estado,
    enviar(evento: Evento) {
      estado = transicion(estado, evento)   // aplica delta
      oyentes.forEach((f) => f(estado))      // notifica a los suscriptores
    },
    suscribir(f: (e: Estado) => void) {
      oyentes.add(f)
      return () => oyentes.delete(f)          // devuelve el cancelador
    },
  }
}

Con eso tienes get para leer, enviar para transicionar y suscribir para reaccionar: el contrato exacto que expone cualquier motor, reducido a su esencia. Si te suena a los signals y al patrón observer de los niveles 3 y 4, es porque lo es. Una máquina de estados no es más que un store cuya escritura está restringida a transiciones legales: le quitas al store la libertad de aceptar cualquier valor y le impones la disciplina de δ.

🔀

switch a mano

Explícito y sin dependencias. Ideal para tres o cuatro estados. El compilador vigila la exhaustividad por ti.

🗂️

Objeto de transiciones

Dirigido por datos, serializable, con acciones de entrada. Escala mejor y se puede visualizar o generar.

⚛️

useReducer / reducer

Ya es una función de transición. Con estado discriminado, es una FSM a la que solo le falta declarar qué saltos son legales.

La frontera: qué justifica un motor

A mano llegas lejos: estados finitos, transiciones, acciones de entrada y salida, y guardas con un poco más de código. El diagrama siguiente resume la máquina completa que acabamos de construir sin instalar nada.

stateDiagram-v2
[*] --> idle
idle --> loading : FETCH
loading --> success : RESOLVE
loading --> error : REJECT
error --> loading : RETRY

Incluso una guarda —una transición condicionada— cabe a mano: basta con que la celda deje de ser un destino fijo y pase a ser una función que mira el contexto y decide. Con eso cubres el reintento con límite de intentos sin instalar nada.

const conGuarda = {
  error: {
    on: {
      RETRY: (ctx: { intentos: number }) =>
        ctx.intentos < 3 ? "loading" : "error",   // guarda: solo si quedan intentos
    },
  },
}

El día que necesitas encadenar varias guardas, contextos ricos y tipados, o que la condición dependa de datos que evolucionan por su cuenta, el código a mano empieza a pedir estructura a gritos. Ese es el primer aviso de que un motor te ahorraría más de lo que te cuesta.

Lo que NO obtienes barato a mano es todo lo que empieza donde acaba esta lección: jerarquía de estados anidados, regiones paralelas ortogonales, transiciones diferidas por tiempo, invocación y generación de actores, historia, y —nada menor— visualización y herramientas de depuración con viaje en el tiempo. Cada una de esas piezas la puedes emular con esfuerzo, pero reimplementarlas bien es reescribir una librería. Ese es el punto exacto en el que delegar tiene sentido.

⚠️
Elige el motor por lo que te falta, no por moda

En 2026 el ecosistema está maduro y estratificado. Para máquinas triviales, tu objeto de transiciones o @xstate/store —el paquete ligero de la familia XState— bastan; nota que el viejo @xstate/fsm está descontinuado, así que no lo adoptes. Cuando aparecen jerarquía, paralelismo, actores o efectos declarativos, XState v5 con su setup tipado es la referencia. Si lo tuyo son componentes de interfaz accesibles y agnósticos del framework, Zag.js encapsula máquinas ya diseñadas. La regla es negativa: no instales un motor por lo que hace, instálalo por la capacidad concreta que a tu switch le falta. Si no sabes nombrar esa capacidad, todavía no la necesitas.

La librería no te da el modelo; te da la ergonomía del modelo

Aquí está la lección que sobrevive a cualquier cambio de moda: el valor de una FSM no vive en la herramienta, vive en la disciplina de pensar tu problema como estados y transiciones. Esa disciplina la ejerces igual de bien con un switch de doce líneas que con el motor más sofisticado, porque la parte difícil —decidir qué estados existen y qué transiciones son legales— es diseño, no código, y ninguna librería la hace por ti. Lo que un motor aporta no es el modelo sino su ergonomía a escala: el día que tu máquina tiene dieciséis estados anidados en tres regiones paralelas con transiciones diferidas, mantener eso a mano se vuelve su propio proyecto, y ahí XState v5 te devuelve horas. Pero si adoptas la librería sin haber interiorizado la mecánica que acabas de escribir a mano, la usarás como una caja negra y reproducirás con ella los mismos estados imposibles que venías a eliminar. El orden correcto de aprendizaje es este y no el inverso: primero domina el switch hasta que te aburra, entiende que tu useReducer ya era una máquina, y solo entonces deja que una librería te quite el trabajo mecánico. Quien empieza por la librería aprende una API; quien empieza por el switch aprende el modelo, y el modelo es lo único que no caduca.

⚔️ Constrúyela con tus manos
  1. Implementa la máquina de petición con el switch y verifica que un RESOLVE en estado idle la deja quieta, sin fallar.
  2. Reescríbela como objeto de transiciones con una acción entry en loading que registre un mensaje, y comprueba que solo se dispara al entrar.
  3. Añade una guarda: que RETRY desde error solo transicione a loading si un contador de intentos es menor que tres.
  4. Toma un useReducer que tengas y reescríbelo para que su estado sea una unión discriminada; observa que se ha convertido en una FSM explícita.
  5. Escribe en una frase qué capacidad concreta te haría instalar XState v5 en tu proyecto actual. Si no puedes nombrarla, no lo instales.