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.

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.
// ❌ 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í.
// ✅ 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.
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.
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.
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.
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.
| Complejidad | Enfoque recomendado |
|---|---|
| 2–4 estados, transiciones lineales | Unión discriminada + useState |
| 4–8 estados, guardas, contexto calculado | Reducer tipado con useReducer |
| Regiones paralelas, invocaciones asíncronas, estados anidados | XState 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
- Reemplaza las banderas booleanas con uniones discriminadas —
status: "idle" | "submitting" | "error" | "success"hace imposibles las combinaciones de estados imposibles a nivel de tipos. - Las transiciones son la fuente de verdad — define qué eventos son válidos en cada estado y devuelve
statesin cambios para todo lo demás. - 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.
- 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. - 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í.


