Buscar en la documentación... ⌘K Buscar en la documentación Busca en todas las páginas de la documentación
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.
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.
"use client" ;
import { useReducer } from "react" ;
// --- Tipos ---
interface FormState {
step : number ;
totalSteps : number ;
fields : {
name : string ;
email :
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_FIELD limpia 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 RESET vuelve a initialState — sin riesgo de estado parcial obsoleto
useReducer acepta una función reductora pura y un estado inicial, devolviendo el estado actual y una función dispatch
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 setState dispersas que pueden desincronizarse
React agrupa múltiples envíos en el mismo manejador de eventos en un único re-renderizado
Preocupación useState (múltiple)useReducerTransiciones 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
Define una unión discriminada para acciones type-safe:
type FormAction =
| { type : "SET_FIELD" ; name : string ; value : string }
| { type : "NEXT_STEP" }
| { type : "PREV_STEP" }
| {
TypeScript estrecha la acción dentro de cada rama case — action.name solo es accesible dentro del caso SET_FIELD.
"use client" ;
import { useReducer, useTransition } from "react" ;
import { submitSignup } from "./actions" ;
function MultiStepForm () {
const [ state , dispatch ] = useReducer (formReducer, initialState);
const [ isPending , startTransition
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"
Deshacer/rehacer con historial de acciones:
interface FormStateWithHistory {
past : FormState [];
present : FormState ;
future : FormState [];
}
type HistoryAction = FormAction | { type : "UNDO" } | { type :
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
// 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
import { describe, it, expect } from "vitest" ;
describe ( "formReducer" , () => {
const initial : FormState = { step: 0 , fields: { name: "" , email: "" }, errors: {} };
it ( "establece un valor de campo" , () => {
Estado obsoleto en callbacks asincronos — Si lees state dentro de un setTimeout o await, obtienes el valor en el momento del envío, no el más reciente. Solución: Usa useRef para 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 fields dentro de state) también deben extenderse: { ...state, fields: { ...state.fields, [name]: value } }. Solución: Siempre extiende en cada nivel de anidación que modifiques, o usa produce de 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, sin Date.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 — dispatch no actualiza state de forma síncrona. Llamar a dispatch({ type: "SET_FIELD" }) y luego leer state.fields da el valor antiguo. Solución: Valida usando el valor que estás a punto de enviar, no el state actual.
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 default faltante en switch — TypeScript no advertirá si te pierdes un caso a menos que agregues comprobación exhaustiva. Solución: Agregar default: { const _exhaustive: never = action; return state; } para detectar acciones no controladas en tiempo de compilación.
Alternativa Usa Cuando No Uses Cuando useState múltipleEl 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 useActionStateFormulario de Server Action con estado pendiente/error Asistente de cliente multi-paso sin round-trip de servidor por paso
¿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 setState en un manejador, cambia a useReducer
¿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 startTransition para el estado pendiente vía useTransition
Envía una acción VALIDATE con errores devueltos por el servidor para mostrarlos en el paso actual
useActionState maneja mejor formularios de servidor de un solo paso; useReducer es 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 desde localStorage
Guarda el estado en localStorage en un useEffect que 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 handleNext antes de enviar NEXT_STEP
Si se encuentran errores, envía VALIDATE con 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_STEP del 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 steps para 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 state desnudo que silenciosamente traga acciones desconocidas
¿Por qué dispatch no actualiza el estado inmediatamente?
dispatch programa un re-renderizado — state refleja 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 usa nextStep
Alternativamente, usa un useEffect que reacciona a cambios de estado
Este es el mismo comportamiento de agrupamiento que useState — no es único para useReducer
¿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 produce de 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?
useReducer es 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)
useReducer maneja 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> — donde FormAction es 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 dispatch a través de muchos niveles de componentes
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 >
);
}
type
:
"SET_ERRORS"
;
errors
:
Record
<
string
,
string
> }
| { type : "SUBMIT" }
| { type : "RESET" };
]
=
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 >
);
}
? state.step + 2
: state.step + 1 ;
return { ... state, step: nextStep, errors: {} };
}
// ... otros casos
}
}
"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: [],
};
}
.
parse
(saved)
:
defaultState;
}
const [ state , dispatch ] = useReducer (formReducer, initialState, initFromStorage);
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;
}
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);
});
});