Saltar al contenido

Máquinas de estados finitos para lógica compleja de UI en TypeScript

Reemplaza el espagueti de banderas booleanas con máquinas de estados finitos: estados de UI en TypeScript donde lo imposible es imposible por construcción.

5 min de lectura
Código TypeScript que muestra una definición de transición de máquina de estados finitos para un flujo de envío de formulario

Las banderas booleanas son un impuesto. Cada nuevo caso límite añade otro isLoading, hasError, isRetrying, wasSubmitted — y pronto estás manteniendo una docena de booleanos que pueden combinarse en estados que nunca deberían existir. Un formulario que es simultáneamente isLoading y hasError por un intento anterior. Un botón que está disabled por tres razones distintas, ninguna de ellas explícita. Las máquinas de estados finitos (FSMs) eliminan toda esta clase de errores a nivel de tipos.

El problema del espagueti booleano

Aquí tienes un componente de formulario asíncrono típico. Te resulta familiar porque todos lo hemos escrito.

tstypescript
// ❌ Implicit states — combinations like (loading=true, error=true) are possible
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
 
async function handleSubmit() {
  setIsLoading(true);
  setError(null); // Easy to forget this reset
  try {
    await submitForm(data);
    setIsSuccess(true);
  } catch (e) {
    setError(e.message);
  } finally {
    setIsLoading(false);
  }
}

La superficie de combinaciones de estados posibles es 2⁴ = 16. Tu componente quizás represente cuatro de ellas de manera significativa, pero el sistema de tipos no puede decirte eso. Las pruebas deben proteger contra estados que lógicamente no pueden existir pero técnicamente sí.

tstypescript
// ✅ Explicit states — only valid combinations exist
type FormState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number };

Una unión discriminada. Cinco estados con nombre. Ninguna combinación imposible.

Modelar transiciones como una máquina

Una máquina de estados finitos tiene tres ingredientes: estados, eventos y transiciones. La máquina se encuentra exactamente en un estado a la vez y se mueve entre estados solo cuando se dispara un evento definido.

tstypescript
type FormState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string };
 
type FormEvent =
  | { type: "SUBMIT" }
  | { type: "RESOLVE" }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };
 
function formReducer(state: FormState, event: FormEvent): FormState {
  switch (state.status) {
    case "idle":
      if (event.type === "SUBMIT") return { status: "submitting" };
      return state;
 
    case "submitting":
      if (event.type === "RESOLVE") return { status: "success" };
      if (event.type === "REJECT") return { status: "error", message: event.message };
      return state;
 
    case "error":
      if (event.type === "SUBMIT") return { status: "submitting" };
      if (event.type === "RESET") return { status: "idle" };
      return state;
 
    case "success":
      if (event.type === "RESET") return { status: "idle" };
      return state;
  }
}

La idea clave: las transiciones no manejadas se ignoran en silencio (return state). Un evento RESOLVE disparado estando en idle no hace nada. Esto elimina toda la categoría de errores de "orden de operaciones incorrecto" sin ninguna codificación defensiva en quien llama.

Conectarlo a React

El reducer se mapea directamente a useReducer. No se requiere biblioteca externa para máquinas de este tamaño.

tstypescript
function ContactForm() {
  const [state, dispatch] = useReducer(formReducer, { status: "idle" });
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: "SUBMIT" });
 
    try {
      await submitContactForm(new FormData(e.currentTarget as HTMLFormElement));
      dispatch({ type: "RESOLVE" });
    } catch (err) {
      dispatch({
        type: "REJECT",
        message: err instanceof Error ? err.message : "Something went wrong",
      });
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {state.status === "error" && (
        // TypeScript narrows here — `message` only exists in the error state
        <p role="alert">{state.message}</p>
      )}
      {state.status === "success" ? (
        <p>Thanks — we'll be in touch.</p>
      ) : (
        <button disabled={state.status === "submitting"}>
          {state.status === "submitting" ? "Sending…" : "Send"}
        </button>
      )}
    </form>
  );
}

state.message en la rama de error se estrecha por la unión discriminada — esa propiedad solo existe cuando status === "error". Sin encadenamiento opcional, sin guarda en tiempo de ejecución. El compilador impone la corrección.

Guardas y estado extendido

Las máquinas reales a menudo necesitan guardas — condiciones que deben cumplirse antes de que se dispare una transición. Un flujo de pago podría permitir PROCEED solo si cart.items.length > 0. Las guardas mantienen las reglas de negocio junto a la transición que protegen en lugar de esparcidas entre los manejadores de eventos.

tstypescript
type CheckoutState =
  | { status: "cart"; items: CartItem[] }
  | { status: "payment"; items: CartItem[]; total: number }
  | { status: "confirming"; orderId: string }
  | { status: "complete"; orderId: string };
 
type CheckoutEvent =
  | { type: "PROCEED" }
  | { type: "ORDER_CREATED"; orderId: string }
  | { type: "CONFIRM" };
 
function checkoutReducer(state: CheckoutState, event: CheckoutEvent): CheckoutState {
  switch (state.status) {
    case "cart": {
      if (event.type !== "PROCEED") return state;
      // Guard: can't proceed with an empty cart
      if (state.items.length === 0) return state;
 
      const total = state.items.reduce(
        (sum, item) => sum + item.price * item.qty,
        0,
      );
      return { status: "payment", items: state.items, total };
    }
 
    case "payment": {
      if (event.type === "ORDER_CREATED") {
        return { status: "confirming", orderId: event.orderId };
      }
      return state;
    }
 
    case "confirming": {
      if (event.type === "CONFIRM") {
        return { status: "complete", orderId: state.orderId };
      }
      return state;
    }
 
    default:
      return state;
  }
}

El cálculo de total ocurre exactamente una vez — en el momento de la transición — y se almacena en el contexto de la máquina. Sin estado derivado recalculado en cada render, sin riesgo de que esté obsoleto mientras un componente hermano se actualiza.

Probar máquinas de forma aislada

Como el reducer es una función pura (state, event) => state, es trivialmente testeable sin montar un componente ni simular hooks.

tstypescript
describe("formReducer", () => {
  it("transitions from idle to submitting on SUBMIT", () => {
    const next = formReducer({ status: "idle" }, { type: "SUBMIT" });
    expect(next).toEqual({ status: "submitting" });
  });
 
  it("ignores RESOLVE while idle", () => {
    const state = { status: "idle" } as const;
    const next = formReducer(state, { type: "RESOLVE" });
    expect(next).toBe(state); // Same reference — no allocation
  });
 
  it("captures error message on REJECT", () => {
    const next = formReducer(
      { status: "submitting" },
      { type: "REJECT", message: "Network timeout" },
    );
    expect(next).toEqual({ status: "error", message: "Network timeout" });
  });
});

Cada prueba se lee como una especificación: dado este estado, cuando se dispara este evento, espera este resultado. Sin árboles montados, sin envoltorios act(), sin configuración asíncrona. Toda la tabla de transiciones puede cubrirse exhaustivamente en unas pocas docenas de pruebas unitarias rápidas.

Cuándo recurrir a una biblioteca

Los reducers hechos a mano cubren la mayoría de los casos de UI. Para orquestaciones más complejas — estados paralelos, máquinas jerárquicas, transiciones retardadas, invocación de servicios asíncronos — XState v5 es la herramienta adecuada. La API de v5 es significativamente más ligera que la de v4 y se integra limpiamente con React a través de useMachine.

~

No necesitas XState para un interruptor de tres estados. Úsalo cuando tu máquina necesite invocar servicios, manejar temporizadores o coordinar regiones paralelas. Sobrediseñar un simple envoltorio de fetch como un diagrama de estados completo es su propio anti-patrón.

ComplejidadEnfoque recomendado
2–4 estados, transiciones linealesUnión discriminada + useState
4–8 estados, guardas, contexto calculadoReducer tipado con useReducer
Regiones paralelas, invocaciones asíncronas, estados anidadosXState v5

El gradiente importa. Un botón de tres estados no necesita un diagrama de estados. Un pago de varios pasos con sondeo en segundo plano, recuperación por tiempo de espera y validación concurrente sí lo necesita.

Puntos clave

  1. Reemplaza las banderas booleanas con uniones discriminadas — status: "idle" | "submitting" | "error" | "success" hace imposibles las combinaciones de estados imposibles a nivel de tipos.
  2. Las transiciones son la fuente de verdad — define qué eventos son válidos en cada estado y devuelve state sin cambios para todo lo demás.
  3. Las guardas pertenecen dentro del reducer — las reglas de negocio que controlan una transición deben vivir junto a la transición misma, no esparcidas entre componentes.
  4. Los reducers puros son trivialmente testeables — sin montar componentes, sin ceremonia asíncrona; prueba cada par (state, event) como una llamada directa a función.
  5. Recurre a XState cuando las máquinas crezcan jerárquicas — las regiones paralelas y las invocaciones de servicios asíncronos son exactamente para lo que fue construido; no luches contra un reducer hecho a mano para llegar allí.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX