Máquinas de Estado para la Lógica de UI — Modelar estados complejos de UI como transiciones de estado finito explícitas
Receta
// Máquina de estado simple con useReducer
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: unknown }
| { status: "error"; error: Error };
type FetchEvent =
| { type: "FETCH" }
| { type: "RESOLVE"; data: unknown }
| { type: "REJECT"; error: Error }
| { type: "RESET" };
function fetchReducer(state: FetchState, event: FetchEvent): FetchState {
switch (state.status) {
case "idle":
if (event.type === "FETCH") return { status: "loading" };
return state;
case "loading":
if (event.type === "RESOLVE") return { status: "success", data: event.data };
if (event.type === "REJECT") return { status: "error", error: event.error };
return state;
case "error":
if (event.type === "FETCH") return { status: "loading" };
if (event.type === "RESET") return { status: "idle" };
return state;
case "success":
if (event.type === "FETCH") return { status: "loading" };
if (event.type === "RESET") return { status: "idle" };
return state;
}
}
const [state, send] = useReducer(fetchReducer, { status: "idle" });Cuándo usarlo: Cuando un componente tiene múltiples estados con transiciones restringidas (por ejemplo, un formulario de múltiples pasos, un flujo asincrónico o un modal con carga/error/éxito). Si te encuentras haciendo malabarismos con múltiples booleanos como isLoading, isError, isSuccess, usa una máquina de estado en su lugar.
Ejemplo de Trabajo
import { useReducer, useCallback, type ReactNode } from "react";
// --- Máquina de estado de formulario de múltiples pasos ---
interface FormData {
name: string;
email: string;
plan: string;
}
type FormState =
| { step: "details"; data: Partial<FormData> }
| { step: "plan"; data: Partial<FormData> }
| { step: "review"; data: FormData }
| { step: "submitting"; data: FormData }
| { step: "complete"; data: FormData }
| { step: "error"; data: FormData; error: string };
type FormEvent =
| { type: "NEXT"; fields: Partial<FormData> }
| { type: "BACK" }
| { type: "SUBMIT" }
| { type: "SUCCESS" }
| { type: "FAIL"; error: string }
| { type: "RETRY" };
function formReducer(state: FormState, event: FormEvent): FormState {
switch (state.step) {
case "details":
if (event.type === "NEXT") {
return { step: "plan", data: { ...state.data, ...event.fields } };
}
return state;
case "plan":
if (event.type === "NEXT") {
const data = { ...state.data, ...event.fields } as FormData;
return { step: "review", data };
}
if (event.type === "BACK") return { step: "details", data: state.data };
return state;
case "review":
if (event.type === "SUBMIT") return { step: "submitting", data: state.data };
if (event.type === "BACK") return { step: "plan", data: state.data };
return state;
case "submitting":
if (event.type === "SUCCESS") return { step: "complete", data: state.data };
if (event.type === "FAIL") {
return { step: "error", data: state.data, error: event.error };
}
return state;
case "error":
if (event.type === "RETRY") return { step: "submitting", data: state.data };
if (event.type === "BACK") return { step: "review", data: state.data };
return state;
case "complete":
return state; // Estado terminal
}
}
function SignupWizard() {
const [state, send] = useReducer(formReducer, {
step: "details",
data: {},
});
const handleSubmit = useCallback(async () => {
send({ type: "SUBMIT" });
try {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(state.step === "review" ? state.data : null),
});
send({ type: "SUCCESS" });
} catch (err) {
send({ type: "FAIL", error: (err as Error).message });
}
}, [state]);
switch (state.step) {
case "details":
return (
<DetailsStep
data={state.data}
onNext={(fields) => send({ type: "NEXT", fields })}
/>
);
case "plan":
return (
<PlanStep
data={state.data}
onNext={(fields) => send({ type: "NEXT", fields })}
onBack={() => send({ type: "BACK" })}
/>
);
case "review":
return (
<ReviewStep
data={state.data}
onSubmit={handleSubmit}
onBack={() => send({ type: "BACK" })}
/>
);
case "submitting":
return <LoadingSpinner message="Creando tu cuenta..." />;
case "error":
return (
<ErrorDisplay
error={state.error}
onRetry={() => send({ type: "RETRY" })}
onBack={() => send({ type: "BACK" })}
/>
);
case "complete":
return <SuccessMessage data={state.data} />;
}
}Lo que esto demuestra:
- Tipos de unión discriminados para estados — cada paso lleva exactamente los datos que necesita
- Guardias de transición explícitas — los eventos solo tienen efecto en el estado correcto
- Los estados imposibles son irrepresentables — ninguna confusión de
isLoading && isError - Efectos secundarios (llamada a API) desencadenados por transiciones de estado, no dispersos entre efectos
Inmersión Profunda
Cómo Funciona
- Una máquina de estado define un conjunto finito de estados y un conjunto finito de eventos (transiciones) entre ellos.
- Cada estado determina qué eventos son válidos. Los eventos inválidos se ignoran (o pueden lanzar un error en desarrollo).
useReduceres la primitiva React integrada para máquinas de estado. La función reductora ES la máquina de estado.- Los tipos de unión discriminados en TypeScript hacen que la forma de cada estado sea explícita y permiten que el compilador refuerce el manejo exhaustivo.
- El
switchenstate.step(ostate.status) actúa como el gráfico de estado; las verificaciones anidadas deifenevent.typedefinen transiciones válidas.
Parámetros y Valores de Retorno
| Concepto | Implementación | Propósito |
|---|---|---|
| Estado | Tipo de unión discriminado | Representa todos los estados posibles con datos asociados |
| Evento | Unión de { type: string; ... } | Todas las entradas posibles que desencadenan transiciones |
| Reductor | (state, event) => state | Función pura que define la lógica de la máquina de estado |
| Despacho | send(event) | Desencadenar una transición de estado |
Variaciones
Condiciones de guardia — permitir transiciones solo cuando se cumplen las condiciones:
case "details":
if (event.type === "NEXT") {
if (!event.fields.name || !event.fields.email) {
return { ...state, validationError: "Todos los campos son requeridos" };
}
return { step: "plan", data: { ...state.data, ...event.fields } };
}
return state;Usar XState para máquinas complejas — cuando la lógica de estado excede lo que useReducer maneja limpiamente:
import { useMachine } from "@xstate/react";
import { createMachine, assign } from "xstate";
const toggleMachine = createMachine({
id: "toggle",
initial: "inactive",
context: { count: 0 },
states: {
inactive: {
on: {
TOGGLE: {
target: "active",
actions: assign({ count: ({ context }) => context.count + 1 }),
},
},
},
active: {
on: { TOGGLE: "inactive" },
},
},
});
function Toggle() {
const [state, send] = useMachine(toggleMachine);
return (
<button onClick={() => send({ type: "TOGGLE" })}>
{state.value} (toggled {state.context.count} times)
</button>
);
}Notas de TypeScript
- Usa uniones discriminadas (uniones etiquetadas) para los tipos de estado. El discriminante (por ejemplo,
step,status) habilita el estrechamiento de tipos. - Siempre haz el
switchexhaustivo. TypeScript te advertirá si pierdes un estado cuando el tipo de retorno está especificado. - Evita campos
booleanen objetos de estado — crean 2^n estados posibles. Usa estados nombrados explícitos en su lugar. - XState v5 tiene soporte de TypeScript de primera clase con
typegenpara tipos de evento inferidos.
Trampa
-
Sopa booleana — Usar
isLoading,isError,hasDatacomo booleanos separados crea combinaciones imposibles comoisLoading && isError. Solución: Reemplaza con un único estado de unión discriminado. -
Efectos secundarios en el reductor — Los reductores deben ser funciones puras. Las llamadas a API o mutaciones de DOM dentro del reductor rompen las reglas de React. Solución: Desencadena efectos secundarios fuera del reductor basándose en transiciones de estado (en manejadores de eventos o efectos).
-
Olvidar el retorno por defecto — Si el reductor no maneja un evento en un estado dado y no devuelve
state, el estado se vuelveundefined. Solución: Siempre agregareturn statecomo predeterminado para eventos no manejados en cada caso de estado. -
Sobre-ingeniería de estado simple — Un toggle que está activado o desactivado no necesita una máquina de estado. Solución: Usa
useState(false)para escenarios triviales de dos estados. Recurre a máquinas cuando tienes 3 o más estados o transiciones complejas.
Alternativas
| Enfoque | Compensación |
|---|---|
Máquina de estado useReducer | Integrada, sin dependencias; lógica de transición manual |
| XState | Poderosa, herramientas visuales; dependencia extra, curva de aprendizaje |
Múltiples booleanos useState | Simple para 1-2 estados; los estados imposibles se vuelven posibles |
| Zustand con campo de estado | Bueno para máquinas de estado global; no es específico de React |
useActionState (React 19) | Diseñado para flujos de envío de formularios; limitado a acciones de formularios |
Preguntas Frecuentes
¿Qué es una máquina de estado en el contexto de la lógica de UI de React?
- Una máquina de estado define un conjunto finito de estados y un conjunto finito de transiciones válidas entre ellos.
- Cada estado determina qué eventos se aceptan. Los eventos inválidos se ignoran.
useReduceres la primitiva React integrada para implementar máquinas de estado.
¿Por qué son importantes los tipos de unión discriminados para máquinas de estado?
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: unknown }
| { status: "error"; error: Error };- Cada estado lleva exactamente los datos que necesita (por ejemplo,
datasolo existe ensuccess). - TypeScript estrecha el tipo cuando cambias en el discriminante (
status), previniendo el acceso a campos inválidos. - Los estados imposibles como
isLoading && isErrorse vuelven irrepresentables.
¿Cuándo deberías usar una máquina de estado en lugar de múltiples booleanos useState?
- Cuando tienes 3 o más estados con transiciones restringidas.
- Cuando hacer malabarismos con booleanos como
isLoading,isError,isSuccesscrea combinaciones imposibles. - Un toggle de dos estados simple no necesita una máquina de estado;
useState(false)es suficiente.
¿Cómo actúa la función reductora como la máquina de estado?
- El
switchexterno enstate.step(ostate.status) define en qué estado estás. - Las verificaciones anidadas de
ifenevent.typedefinen qué transiciones son válidas en ese estado. - El
return statepredeterminado ignora eventos que no son válidos para el estado actual.
Trampa: ¿Por qué los reductores deben ser funciones puras?
- React puede llamar a los reductores varias veces durante el renderizado (modo estricto, características concurrentes).
- Los efectos secundarios como llamadas a API o mutaciones de DOM dentro del reductor rompen las reglas de React y causan comportamiento impredecible.
- Solución: desencadena efectos secundarios en manejadores de eventos o
useEffectbasándose en transiciones de estado.
Trampa: ¿Qué sucede si el reductor olvida devolver state para eventos no manejados?
- El estado se vuelve
undefined, que puede bloquear el componente o causar errores silenciosos. - Siempre agrega
return statecomo el caso predeterminado para cada estado en la declaración switch. - TypeScript puede ayudarte a detectar esto con un tipo de retorno explícito en el reductor.
¿Cómo agregas condiciones de guardia a las transiciones de estado?
case "details":
if (event.type === "NEXT") {
if (!event.fields.name || !event.fields.email) {
return { ...state, validationError: "Todos los campos son requeridos" };
}
return { step: "plan", data: { ...state.data, ...event.fields } };
}
return state;- Verifica las condiciones antes de realizar la transición.
- Devuelve un estado actual modificado (por ejemplo, con un error) si la guardia falla.
¿Cómo haces que TypeScript refuerce el manejo exhaustivo del estado?
- Especifica el tipo de retorno en la función reductora explícitamente.
- TypeScript te advertirá si pierdes un caso en la declaración switch.
- Usa una verificación
neveren el caso predeterminado para detectar estados no manejados en tiempo de compilación.
¿Cuándo deberías usar XState en lugar de useReducer?
- Usa XState para máquinas complejas con estados paralelos, estados jerárquicos o transiciones retrasadas.
- XState proporciona herramientas visuales (editor de gráfico de estado) y soporte de TypeScript de primera clase.
- Para flujos lineales simples,
useReduceres suficiente y tiene cero dependencias.
¿Por qué deberías evitar campos booleanos en objetos de estado?
ncampos booleanos crean2^ncombinaciones posibles, la mayoría de las cuales son inválidas.- Ejemplo:
{ isLoading: true, isError: true }es un estado imposible que los booleanos permiten. - Solución: usa una única unión discriminada con estados nombrados como
"idle" | "loading" | "error" | "success".
¿Cómo manejas efectos secundarios como llamadas a API desencadenadas por transiciones de estado?
- Realiza la llamada a API en el manejador de eventos o en un
useEffectque observe el estado. - Distribuye eventos como
SUCCESSoFAILbasándose en la respuesta de la API. - Mantén el reductor puro; solo calcula el siguiente estado.
Relacionado
- Controlado vs No controlado — Patrones de propiedad de estado que estas máquinas a menudo manejan
- Límites de Error — Estados de error en el árbol de componentes
- Suspense — Máquina de estado de carga integrada de React