useReducer Formularios Multi-Paso
Gestiona el estado complejo de formularios multi-paso con useReducer — acciones explícitas como SET_FIELD, NEXT_STEP y VALIDATE hacen que las transiciones de estado sean predecibles, probables y fáciles de depurar sin necesidad de recurrir a bibliotecas externas.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
"use client";
import { useReducer } from "react";
interface FormState {
step: number;
fields: Record<string, string>;
errors: Record<string, string>;
}
type FormAction =
| { type: "SET_FIELD"; name: string; value: string }
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "VALIDATE"; errors: Record<string, string> }
| { type: "RESET" };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return { ...state, fields: { ...state.fields, [action.name]: action.value }, errors: {} };
case "NEXT_STEP":
return { ...state, step: state.step + 1, errors: {} };
case "PREV_STEP":
return { ...state, step: Math.max(0, state.step - 1) };
case "VALIDATE":
return { ...state, errors: action.errors };
case "RESET":
return { step: 0, fields: {}, errors: {} };
}
}Cuándo usarlo: Cuando tu formulario tiene 3+ pasos, validación entre pasos, o transiciones de estado lo suficientemente complejas como para que malabarear múltiples llamadas a useState sea propenso a errores.
Ejemplo Funcional
"use client";
import { useReducer } from "react";
// --- Tipos ---
interface FormState {
step: number;
totalSteps: number;
fields: {
name: string;
email: string;
company: string;
role: string;
plan: string;
cardNumber: string;
agree: boolean;
};
errors: Record<string, string>;
submitted: boolean;
}
type FormAction =
| { type: "SET_FIELD"; name: keyof FormState["fields"]; value: string | boolean }
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "VALIDATE"; errors: Record<string, string> }
| { type: "SUBMIT" }
| { type: "RESET" };
// --- Estado inicial ---
const initialState: FormState = {
step: 0,
totalSteps: 3,
fields: {
name: "",
email: "",
company: "",
role: "",
plan: "free",
cardNumber: "",
agree: false,
},
errors: {},
submitted: false,
};
// --- Reductor (función pura — fácil de probar unitariamente) ---
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return {
...state,
fields: { ...state.fields, [action.name]: action.value },
errors: { ...state.errors, [action.name]: "" },
};
case "NEXT_STEP":
return { ...state, step: state.step + 1, errors: {} };
case "PREV_STEP":
return { ...state, step: Math.max(0, state.step - 1), errors: {} };
case "VALIDATE":
return { ...state, errors: action.errors };
case "SUBMIT":
return { ...state, submitted: true };
case "RESET":
return initialState;
}
}
// --- Validación por paso ---
function validateStep(step: number, fields: FormState["fields"]): Record<string, string> {
const errors: Record<string, string> = {};
if (step === 0) {
if (!fields.name.trim()) errors.name = "El nombre es obligatorio";
if (!fields.email.includes("@")) errors.email = "Se requiere un correo electrónico válido";
}
if (step === 1) {
if (!fields.company.trim()) errors.company = "La empresa es obligatoria";
if (!fields.role.trim()) errors.role = "El puesto es obligatorio";
}
if (step === 2) {
if (fields.plan !== "free" && fields.cardNumber.length < 16)
errors.cardNumber = "Se requiere un número de tarjeta válido para planes pagos";
if (!fields.agree) errors.agree = "Debe aceptar los términos";
}
return errors;
}
// --- Componente ---
export default function MultiStepSignup() {
const [state, dispatch] = useReducer(formReducer, initialState);
const { step, totalSteps, fields, errors, submitted } = state;
function handleNext() {
const stepErrors = validateStep(step, fields);
if (Object.keys(stepErrors).length > 0) {
dispatch({ type: "VALIDATE", errors: stepErrors });
return;
}
if (step < totalSteps - 1) {
dispatch({ type: "NEXT_STEP" });
} else {
dispatch({ type: "SUBMIT" });
}
}
if (submitted) {
return (
<div className="rounded-lg bg-green-50 p-8 text-center">
<h2 className="text-xl font-semibold text-green-800">¡Bienvenido, {fields.name}!</h2>
<p className="mt-2 text-green-600">Tu plan {fields.plan} está activo.</p>
<button
onClick={() => dispatch({ type: "RESET" })}
className="mt-4 rounded bg-green-600 px-4 py-2 text-white"
>
Comenzar de Nuevo
</button>
</div>
);
}
return (
<div className="mx-auto max-w-md">
{/* Barra de progreso */}
<div className="mb-6 flex gap-2">
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={`h-2 flex-1 rounded ${i <= step ? "bg-blue-600" : "bg-gray-200"}`}
/>
))}
</div>
{/* Paso 0: Información Personal */}
{step === 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Información Personal</h2>
<div>
<input
type="text"
placeholder="Nombre completo"
value={fields.name}
onChange={(e) => dispatch({ type: "SET_FIELD", name: "name", value: e.target.value })}
className="w-full rounded border p-2"
/>
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
</div>
<div>
<input
type="email"
placeholder="Correo electrónico"
value={fields.email}
onChange={(e) => dispatch({ type: "SET_FIELD", name: "email", value: e.target.value })}
className="w-full rounded border p-2"
/>
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email}</p>}
</div>
</div>
)}
{/* Paso 1: Información de la Empresa */}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Detalles de la Empresa</h2>
<div>
<input
type="text"
placeholder="Empresa"
value={fields.company}
onChange={(e) =>
dispatch({ type: "SET_FIELD", name: "company", value: e.target.value })
}
className="w-full rounded border p-2"
/>
{errors.company && <p className="mt-1 text-sm text-red-600">{errors.company}</p>}
</div>
<div>
<input
type="text"
placeholder="Puesto"
value={fields.role}
onChange={(e) => dispatch({ type: "SET_FIELD", name: "role", value: e.target.value })}
className="w-full rounded border p-2"
/>
{errors.role && <p className="mt-1 text-sm text-red-600">{errors.role}</p>}
</div>
</div>
)}
{/* Paso 2: Plan y Pago */}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Elige tu Plan</h2>
<select
value={fields.plan}
onChange={(e) => dispatch({ type: "SET_FIELD", name: "plan", value: e.target.value })}
className="w-full rounded border p-2"
>
<option value="free">Gratis</option>
<option value="pro">Pro — $19/mes</option>
<option value="enterprise">Enterprise — $99/mes</option>
</select>
{fields.plan !== "free" && (
<div>
<input
type="text"
placeholder="Número de tarjeta"
value={fields.cardNumber}
onChange={(e) =>
dispatch({ type: "SET_FIELD", name: "cardNumber", value: e.target.value })
}
className="w-full rounded border p-2"
/>
{errors.cardNumber && (
<p className="mt-1 text-sm text-red-600">{errors.cardNumber}</p>
)}
</div>
)}
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={fields.agree as boolean}
onChange={(e) =>
dispatch({ type: "SET_FIELD", name: "agree", value: e.target.checked })
}
/>
<span className="text-sm">Acepto los términos de servicio</span>
</label>
{errors.agree && <p className="mt-1 text-sm text-red-600">{errors.agree}</p>}
</div>
)}
{/* Navegación */}
<div className="mt-6 flex justify-between">
<button
onClick={() => dispatch({ type: "PREV_STEP" })}
disabled={step === 0}
className="rounded bg-gray-200 px-4 py-2 disabled:opacity-50"
>
Atrás
</button>
<button onClick={handleNext} className="rounded bg-blue-600 px-4 py-2 text-white">
{step === totalSteps - 1 ? "Enviar" : "Siguiente"}
</button>
</div>
</div>
);
}Lo que demuestra esto:
- Todo el estado del formulario en un único
useReducer— índice de paso, valores de campo, errores y estado de envío - Validación por paso que se ejecuta solo en "Siguiente" — los usuarios no están bloqueados por errores en pasos futuros
SET_FIELDlimpia el error para ese campo específico, proporcionando retroalimentación en línea- El reductor es una función pura sin efectos secundarios, lo que la hace trivialmente testeable unitariamente
- La acción
RESETvuelve ainitialState— sin riesgo de estado parcial obsoleto
Análisis Profundo
Cómo Funciona
useReduceracepta una función reductora pura y un estado inicial, devolviendo el estado actual y una funcióndispatch- Cada cambio de estado es una acción explícita con una cadena
type— puedes registrar, reproducir o deshacer acciones - El reductor centraliza todas las transiciones de estado en un lugar, eliminando las llamadas
setStatedispersas que pueden desincronizarse - React agrupa múltiples envíos en el mismo manejador de eventos en un único re-renderizado
Por Qué useReducer en Lugar de useState para Formularios Multi-Paso
| Preocupación | useState (múltiple) | useReducer |
|---|---|---|
| Transiciones de estado | Dispersas en manejadores | Centralizadas en el reductor |
| Pruebas | Debe montar componente | Prueba el reductor como función pura |
| Depuración | Comprueba cada setter | Registra acciones enviadas |
| Actualizaciones coordinadas | Riesgo de actualizaciones parciales | Envío único y atómico |
| Agregar nuevos campos | Otra llamada a useState | Agregar a la interfaz de estado |
Patrón de Acción
Define una unión discriminada para acciones type-safe:
type FormAction =
| { type: "SET_FIELD"; name: string; value: string }
| { type: "NEXT_STEP" }
| { type: "PREV_STEP" }
| { type: "SET_ERRORS"; errors: Record<string, string> }
| { type: "SUBMIT" }
| { type: "RESET" };TypeScript estrecha la acción dentro de cada rama case — action.name solo es accesible dentro del caso SET_FIELD.
Combinación con Server Actions
"use client";
import { useReducer, useTransition } from "react";
import { submitSignup } from "./actions";
function MultiStepForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const [isPending, startTransition] = useTransition();
async function handleSubmit() {
const errors = validateStep(state.step, state.fields);
if (Object.keys(errors).length > 0) {
dispatch({ type: "VALIDATE", errors });
return;
}
startTransition(async () => {
const result = await submitSignup(state.fields);
if (result.errors) {
dispatch({ type: "VALIDATE", errors: result.errors });
} else {
dispatch({ type: "SUBMIT" });
}
});
}
return (
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "Enviando..." : "Enviar"}
</button>
);
}Variaciones
Asistente de ramificación (pasos condicionales):
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "NEXT_STEP": {
// Omite paso de pago si el plan es "free"
const nextStep = state.step === 1 && state.fields.plan === "free"
? state.step + 2
: state.step + 1;
return { ...state, step: nextStep, errors: {} };
}
// ... otros casos
}
}Deshacer/rehacer con historial de acciones:
interface FormStateWithHistory {
past: FormState[];
present: FormState;
future: FormState[];
}
type HistoryAction = FormAction | { type: "UNDO" } | { type: "REDO" };
function undoableReducer(state: FormStateWithHistory, action: HistoryAction): FormStateWithHistory {
if (action.type === "UNDO") {
const previous = state.past[state.past.length - 1];
if (!previous) return state;
return {
past: state.past.slice(0, -1),
present: previous,
future: [state.present, ...state.future],
};
}
if (action.type === "REDO") {
const next = state.future[0];
if (!next) return state;
return {
past: [...state.past, state.present],
present: next,
future: state.future.slice(1),
};
}
return {
past: [...state.past, state.present],
present: formReducer(state.present, action),
future: [],
};
}Inicializador lazy para borradores guardados:
function initFromStorage(defaultState: FormState): FormState {
if (typeof window === "undefined") return defaultState;
const saved = localStorage.getItem("signup-draft");
return saved ? JSON.parse(saved) : defaultState;
}
const [state, dispatch] = useReducer(formReducer, initialState, initFromStorage);Notas de TypeScript
// Nombres de campo fuertemente tipados — detecta errores tipográficos en tiempo de compilación
interface SignupFields {
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
type SetFieldAction = {
[K in keyof SignupFields]: { type: "SET_FIELD"; name: K; value: SignupFields[K] };
}[keyof SignupFields];
// Ahora dispatch({ type: "SET_FIELD", name: "plan", value: "invalid" }) es un error de tipo
// Contexto de envío tipado para evitar prop drilling
import { createContext, useContext, type Dispatch } from "react";
const FormDispatchContext = createContext<Dispatch<FormAction> | null>(null);
function useFormDispatch(): Dispatch<FormAction> {
const dispatch = useContext(FormDispatchContext);
if (!dispatch) throw new Error("useFormDispatch debe usarse dentro de FormProvider");
return dispatch;
}Pruebas Unitarias del Reductor
import { describe, it, expect } from "vitest";
describe("formReducer", () => {
const initial: FormState = { step: 0, fields: { name: "", email: "" }, errors: {} };
it("establece un valor de campo", () => {
const next = formReducer(initial, { type: "SET_FIELD", name: "name", value: "Alice" });
expect(next.fields.name).toBe("Alice");
});
it("avanza al siguiente paso", () => {
const next = formReducer(initial, { type: "NEXT_STEP" });
expect(next.step).toBe(1);
expect(next.errors).toEqual({});
});
it("limpia el error de campo en SET_FIELD", () => {
const withError = { ...initial, errors: { name: "Required" } };
const next = formReducer(withError, { type: "SET_FIELD", name: "name", value: "Alice" });
expect(next.errors.name).toBe("");
});
it("no va por debajo del paso 0", () => {
const next = formReducer(initial, { type: "PREV_STEP" });
expect(next.step).toBe(0);
});
it("restablece al estado inicial", () => {
const modified = { step: 2, fields: { name: "Alice", email: "a@b.com" }, errors: {} };
const next = formReducer(modified, { type: "RESET" });
expect(next).toEqual(initial);
});
});Trampa
-
Estado obsoleto en callbacks asincronos — Si lees
statedentro de unsetTimeoutoawait, obtienes el valor en el momento del envío, no el más reciente. Solución: UsauseRefpara espejar el estado actual, o envía una acción desde el callback asincrónico en lugar de leer el estado. -
Object spread solo crea copias superficiales — Los objetos anidados (como
fieldsdentro destate) también deben extenderse:{ ...state, fields: { ...state.fields, [name]: value } }. Solución: Siempre extiende en cada nivel de anidación que modifiques, o usaproducede Immer. -
Olvidar limpiar errores al cambiar el campo — Los usuarios corrigen el error pero el mensaje persiste. Solución: Limpia el error específico del campo dentro de
SET_FIELD, como se muestra en el ejemplo funcional. -
El reductor debe ser puro — Sin llamadas a API, sin lecturas de
localStorage, sinDate.now()dentro del reductor. Solución: Realiza efectos secundarios en el componente o manejador de eventos, luego envía el resultado. -
La validación se ejecuta contra el estado obsoleto después del envío —
dispatchno actualizastatede forma síncrona. Llamar adispatch({ type: "SET_FIELD" })y luego leerstate.fieldsda el valor antiguo. Solución: Valida usando el valor que estás a punto de enviar, no elstateactual. -
Los reductores grandes se vuelven difíciles de leer — Una sentencia switch de 200 líneas es peor que 8 llamadas a
useState. Solución: Extrae manejadores de casos en funciones nombradas:case "SET_FIELD": return handleSetField(state, action);. -
Caso
defaultfaltante en switch — TypeScript no advertirá si te pierdes un caso a menos que agregues comprobación exhaustiva. Solución: Agregardefault: { const _exhaustive: never = action; return state; }para detectar acciones no controladas en tiempo de compilación.
Alternativas
| Alternativa | Usa Cuando | No Uses Cuando |
|---|---|---|
useState múltiple | El formulario tiene 1-3 campos, sin multi-paso | Las transiciones de estado se coordinan entre campos |
| React Hook Form | Necesitas validación integrada, gestión del enfoque y registro de campos | Quieres control total sobre la forma y transiciones de estado |
| Zustand / Jotai | El estado del formulario debe persistir entre rutas o compartirse globalmente | El estado es local a un árbol de componentes |
| XState | El formulario tiene lógica de ramificación compleja (omitir pasos, bucles, estados paralelos) | Un asistente lineal con validación simple |
useActionState | Formulario de Server Action con estado pendiente/error | Asistente de cliente multi-paso sin round-trip de servidor por paso |
FAQs
¿Cuándo debo usar useReducer en lugar de useState para formularios?
- Cuando el formulario tiene 4+ campos con estado interdependiente (p. ej., cambiar un campo afecta la validación de otro)
- Cuando tienes navegación multi-paso donde paso, campos y errores deben mantenerse sincronizados
- Cuando quieres probar unitariamente transiciones de estado sin montar un componente React
- Cuando múltiples manejadores de eventos necesitan actualizar el mismo estado de diferentes maneras
- Regla de oro: si encuentras que estás llamando a 3+ funciones
setStateen un manejador, cambia auseReducer
¿Puedo usar useReducer con Server Actions?
- Sí — gestiona el estado del asistente del lado del cliente con
useReducer, luego llama a la Server Action en el envío final - Envuelve la llamada de Server Action en
startTransitionpara el estado pendiente víauseTransition - Envía una acción
VALIDATEcon errores devueltos por el servidor para mostrarlos en el paso actual useActionStatemaneja mejor formularios de servidor de un solo paso;useReduceres para asistentes multi-paso del cliente
¿Cómo persisto el estado del formulario entre navegaciones de página?
- Usa el inicializador lazy (tercer argumento para
useReducer) para cargar desdelocalStorage - Guarda el estado en
localStorageen unuseEffectque se ejecuta en cada cambio de estado - Alternativamente, levanta el reductor a un proveedor de contexto que envuelve el grupo de ruta
- Para el estado persistido en URL, serializa el paso y campos clave en parámetros de búsqueda
¿Cómo valido solo el paso actual?
- Escribe una función
validateStep(stepIndex, fields)que solo compruebe los campos relevantes para ese paso - Llámala en
handleNextantes de enviarNEXT_STEP - Si se encuentran errores, envía
VALIDATEcon los errores y retorna temprano - Esto evita que los usuarios sean bloqueados por errores en pasos que aún no han alcanzado
¿Debería el reductor manejar operaciones asincronos como llamadas a API?
- No — los reductores deben ser funciones puras sin efectos secundarios
- Realiza trabajo asincrónico en el manejador de eventos o
useEffect, luego envía el resultado - Ejemplo:
const result = await saveStep(fields); dispatch({ type: "SAVE_SUCCESS", data: result }); - Esto mantiene el reductor testeable y predecible
¿Cómo manejo pasos condicionales/de ramificación?
- Agrega lógica de ramificación dentro del caso
NEXT_STEPdel reductor - Comprueba los valores de los campos para determinar a qué índice de paso saltar
- Ejemplo: omite el paso de pago si
plan === "free"incrementando el paso en 2 - Mantén un array o mapa de
stepspara que la barra de progreso refleje la ruta actual
¿Cómo hago que TypeScript detecte casos de acción faltantes?
- Usa una unión discriminada para tu tipo de acción
- Agrega comprobación exhaustiva en el caso
default:const _exhaustive: never = action; return state; - TypeScript arrojará un error en tiempo de compilación si agregas un nuevo tipo de acción pero olvidas manejarlo
- Esto es más seguro que un
default: return statedesnudo que silenciosamente traga acciones desconocidas
¿Por qué dispatch no actualiza el estado inmediatamente?
dispatchprograma un re-renderizado —staterefleja el nuevo valor en el siguiente renderizado, no de forma síncrona- Si necesitas actuar sobre el valor "nuevo", calcúlalo tu mismo:
const nextStep = step + 1; dispatch(...)luego usanextStep - Alternativamente, usa un
useEffectque reacciona a cambios de estado - Este es el mismo comportamiento de agrupamiento que
useState— no es único parauseReducer
¿Cómo evito una sentencia switch masiva en el reductor?
- Extrae cada caso en una función manejadora nombrada:
case "SET_FIELD": return handleSetField(state, action); - Agrupa acciones relacionadas en sub-reductores si el formulario tiene secciones distintas
- Considera un mapa de manejadores:
const handlers: Record<string, Handler> = { SET_FIELD: handleSetField, ... } - Si el reductor excede ~50 líneas, es señal de dividirlo
¿Puedo usar Immer con useReducer para actualizaciones más simples?
- Sí — envuelve tu reductor con
producede Immer:const reducer = produce((draft, action) => { draft.fields.name = action.value; }) - Esto te permite escribir código "mutante" que produce actualizaciones inmutables bajo el capó
- Especialmente útil cuando el estado está profundamente anidado (p. ej.,
state.steps[2].fields.address.city) - Suma ~5KB pero elimina el boilerplate de spread-en-cada-nivel
¿Cómo se compara esto con XState para formularios de asistente?
useReduceres más simple: solo una sentencia switch, sin biblioteca, sin curva de aprendizaje- XState es mejor cuando los pasos pueden ramificarse, hacer bucles o ejecutarse en paralelo (comportamiento verdadero de máquina de estados)
useReducermaneja asistentes lineales y de ramificación simple bien- Si encuentras que estás agregando booleanos
canGoToStep3, considera actualizar a XState
¿Cuál es el tipo de TypeScript para la función dispatch?
React.Dispatch<FormAction>— dondeFormActiones tu unión discriminada- Pásalo a través del contexto:
createContext<Dispatch<FormAction> | null>(null) - Crea un hook tipado:
function useFormDispatch() { const d = useContext(Ctx); if (!d) throw ...; return d; } - Esto evita prop-drilling
dispatcha través de muchos niveles de componentes
Relacionado
- Patrones de Formulario Básico — Inputs controlados, formularios de un solo paso con useState
- Patrones de Formulario Complejo — Asistentes multi-paso usando React Hook Form
- Formularios de Server Action — Mejora progresiva con useActionState
- Mostrar Errores del Formulario — Patrones para mostrar errores de validación
- React Hook Form — Gestión de formularios basada en biblioteca como alternativa