La lección anterior terminó señalando el límite: ProveedorUsuario guardaba un dato simple, pero el estado de reservas de CicloUrbano tiene cuatro piezas que cambian juntas —la lista de reservas, el borrador en curso, la fase de envío y el posible error— y manejadores que tocan tres a la vez. Con useState eso se convierte en un puñado de variables sueltas, lógica de transición repartida por media docena de funciones y estados imposibles que nadie ha prohibido explícitamente. useReducer cambia el planteamiento: en lugar de modificar el estado desde muchos sitios, los componentes despachan acciones que describen lo que ha ocurrido, y una única función pura decide cómo pasa el estado de una forma a otra. En esta lección verás cuándo toca dar ese salto, qué es exactamente un reductor, cómo se diseñan las acciones, el caso completo de reductorReservas para CicloUrbano, cómo se prueba un reductor sin React y cómo combinarlo con useContext para compartir estado en todo el árbol.
Contenido
- Síntomas de que
useStatese ha quedado corto - Qué es un reductor y qué significa que sea puro
- La firma de
useReducer - Anatomía de una acción y convención de nombres
despachares estable: por qué eso importa- El flujo completo, paso a paso
- Caso CicloUrbano:
reductorReservasal completo - La tercera forma: inicialización perezosa
useStatefrente auseReducer: tabla de decisión- Probar un reductor sin React
useReducer+useContext: estado compartido
- Síntomas de que
useState se ha quedado corto
useState se ha quedado cortouseReducer no es «el useState avanzado» ni una mejora automática. Es una herramienta para una situación concreta, y esta es la lista de síntomas que la anuncian.
Síntoma 1: muchos useState relacionados
// Panel de reservas de CicloUrbano con useState disperso
const [reservas, setReservas] = useState(reservasIniciales);
const [borrador, setBorrador] = useState(DATOS_INICIALES);
const [estadoEnvio, setEstadoEnvio] = useState('inactivo');
const [error, setError] = useState(null);Cuatro variables que nunca se usan por separado: toda operación real toca al menos dos.
Síntoma 2: manejadores que tocan tres estados a la vez
function manejarConfirmar(idReserva) {
setEstadoEnvio('enviando');
setError(null);
setReservas((previas) =>
previas.map((r) => (r.id === idReserva ? { ...r, estado: 'confirmada' } : r))
);
setBorrador(DATOS_INICIALES);
setEstadoEnvio('enviado');
}El problema no es la longitud: es que la regla de negocio está repartida en cuatro llamadas, y si mañana confirmar una reserva también debe descontar una plaza de la estación, hay que acordarse de añadir la quinta. En todos los manejadores.
Síntoma 3: estados imposibles que nadie prohíbe
Nada impide que estadoEnvio valga 'enviando' y error tenga texto al mismo tiempo. La estructura lo permite y solo la disciplina lo evita. Con un reductor, las transiciones válidas se escriben una vez, en un solo sitio, y lo que no está escrito no puede ocurrir.
Síntoma 4: la misma lógica repetida en varios manejadores
Cancelar, caducar y rechazar una reserva hacen casi lo mismo. Con useState acabas copiando el map tres veces; con un reductor, las tres acciones caen en un switch donde la duplicación salta a la vista y se factoriza.
Regla práctica: si un cambio de estado necesita más de dos llamadas al actualizador, o si dos variables de estado nunca cambian por separado, prueba con
useReducer.
- Qué es un reductor y qué significa que sea puro
Un reductor (reducer) es una función que recibe el estado actual y una acción, y devuelve el estado nuevo:
(estadoPrevio, accion) => nuevoEstado
El nombre viene del método reduce de los arrays, que también toma un acumulador y un elemento y devuelve el acumulador siguiente. Un reductor de React hace lo mismo con el historial de acciones: si aplicaras todas las acciones desde el principio, obtendrías el estado actual.
// Un reductor mínimo, para ver la forma
function reductorHoras(horas, accion) {
switch (accion.tipo) {
case 'hora_añadida':
return horas + 1;
case 'hora_quitada':
return Math.max(1, horas - 1);
case 'horas_fijadas':
return accion.horas;
default:
throw new Error(`Acción desconocida: ${accion.tipo}`);
}
}Un reductor debe ser puro, y aquí «puro» significa exactamente tres cosas:
| Requisito | Qué implica | Qué NO puedes hacer dentro |
|---|---|---|
| Determinista | Los mismos argumentos producen siempre el mismo resultado | Math.random(), Date.now(), crypto.randomUUID() |
| Sin efectos secundarios | No toca nada de fuera | fetch, localStorage, console.log con contadores, mutar variables externas |
| Sin mutar los argumentos | El estado previo se copia, no se modifica | estado.reservas.push(x), estado.error = 'algo' |
// ❌ IMPURO: fecha y aleatorio dentro del reductor
case 'reserva_creada':
return {
...estado,
reservas: [...estado.reservas, {
id: `res-${crypto.randomUUID().slice(0, 8)}`, // ⚠️ no determinista
creadaEn: new Date().toISOString() // ⚠️ no determinista
}]
};
// ✅ PURO: los valores no deterministas llegan YA calculados dentro de la acción
case 'reserva_creada':
return { ...estado, reservas: [...estado.reservas, accion.reserva] };La regla se resuelve siempre igual: lo no determinista se calcula en el manejador y viaja dentro de la acción. Es el mismo criterio de 04-05 al generar el identificador de incidencia fuera del render.
¿Por qué tanta insistencia? Por tres motivos prácticos: React puede llamar al reductor dos veces con StrictMode para detectar impurezas; una función pura se puede probar sin montar nada (apartado 10); y un reductor puro hace que el estado sea reproducible, lo que permite herramientas de depuración que rebobinan el historial de acciones.
- La firma de
useReducer
useReducer| Argumento / valor | Qué es |
|---|---|
reductor |
La función (estadoPrevio, accion) => nuevoEstado. Se declara fuera del componente |
estadoInicial |
El estado del primer render |
estado |
El estado del render en curso. Se lee, nunca se asigna |
despachar |
Función que envía una acción al reductor y provoca un render |
La simetría con useState es deliberada: los dos devuelven un par [valor, forma de cambiarlo] y los dos siguen la regla de la instantánea de 05-01. Llamar a despachar no cambia estado en el render en curso: encola la acción, React procesa la cola y el nuevo valor aparece en el render siguiente.
function manejarAñadirHora() {
despachar({ tipo: 'hora_añadida' });
console.log(estado.horas); // el valor VIEJO: misma instantánea que con useState
}El reductor se declara fuera del componente. No necesita nada del ámbito del componente —recibe todo lo que le hace falta como argumentos—, así que ponerlo dentro solo conseguiría recrearlo en cada render y complicar su prueba.
- Anatomía de una acción y convención de nombres
Una acción es un objeto plano que describe algo que ha ocurrido. Por convención lleva un campo identificador y los datos que necesite:
{ tipo: 'reserva_confirmada', idReserva: 'res-01' }
{ tipo: 'borrador_actualizado', campo: 'horas', valor: 4 }
{ tipo: 'envio_fallido', mensaje: 'El servidor respondió 503' }En el ecosistema el campo se llama type (y en Redux es obligatorio que se llame así, como verás en 07-04). En este curso lo escribimos tipo porque no es una API de React sino una convención nuestra, y nuestros nombres van en español. Los datos adicionales se llaman genéricamente carga (payload): puedes ponerlos sueltos —como arriba— o agrupados en carga, siempre que seas coherente.
La regla de oro del nombrado
Nombra las acciones por lo que ha ocurrido, no por lo que hay que hacer.
| ❌ Nombre imperativo | ✅ Nombre declarativo | Por qué es mejor |
|---|---|---|
poner_estado |
reserva_confirmada |
Dice qué pasó; el reductor decide las consecuencias |
set_cargando |
envio_iniciado |
Una acción puede cambiar varias piezas a la vez |
actualizar_reservas |
reserva_cancelada |
Se lee como un registro del negocio |
limpiar_todo |
formulario_reiniciado |
Se entiende sin leer el reductor |
La diferencia parece cosmética y no lo es. Con poner_estado el componente decide cómo cambia el estado, y la lógica vuelve a estar repartida: es useState con más ceremonia. Con reserva_confirmada, el componente solo informa del hecho y el reductor concentra todas las consecuencias. Si mañana confirmar también debe vaciar el borrador y registrar la hora, se toca una sola línea del reductor y todos los sitios que despachan esa acción quedan actualizados.
Una lista de acciones bien nombrada se lee como la crónica de lo que puede pasar en la aplicación:
reserva_creada · reserva_confirmada · reserva_cancelada borrador_actualizado · borrador_reiniciado envio_iniciado · envio_completado · envio_fallido
despachar es estable: por qué eso importa
despachar es estable: por qué eso importaIgual que los actualizadores de useState (05-01), React garantiza que despachar es la misma función en todos los renders. Nunca se recrea. Tres consecuencias directas:
- No entra en las dependencias de un efecto (05-02). Puedes despachar desde dentro de un efecto sin provocar reejecuciones.
- No hay que envolverla en
useCallback(08-03) para pasarla a un hijo memorizado: ya es estable. - Se puede meter en un contexto sin coste: el valor del contexto no cambia por su culpa (apartado 11).
Esto tiene un efecto de segundo orden muy valioso. Compara los dos efectos:
// Con useState: el efecto necesita el estado actual, así que depende de él
useEffect(() => {
const identificador = setInterval(() => {
setReservas((previas) => previas.map(caducarSiProcede)); // forma funcional obligatoria
}, 60000);
return () => clearInterval(identificador);
}, []);
// Con useReducer: el efecto no necesita saber NADA del estado
useEffect(() => {
const identificador = setInterval(() => {
despachar({ tipo: 'reservas_caducadas_revisadas' });
}, 60000);
return () => clearInterval(identificador);
}, []); // ni una dependencia, y sin trucosEl segundo efecto es más limpio porque separa el disparador de la consecuencia: el temporizador solo anuncia que ha pasado un minuto; qué significa eso para las reservas lo decide el reductor. Es la forma más eficaz de simplificar efectos complicados.
- El flujo completo, paso a paso
flowchart TD
A["El usuario pulsa «Confirmar»"] --> B["Manejador: manejarConfirmar(id)"]
B --> C["despachar({ tipo: 'reserva_confirmada', idReserva: id })"]
C --> D["React encola la acción"]
D --> E["reductorReservas(estadoPrevio, accion)"]
E --> F["Devuelve un OBJETO DE ESTADO NUEVO"]
F --> G["React compara y programa un render"]
G --> H["El componente se renderiza con el estado nuevo"]
H --> I["Pantalla actualizada"]
style C fill:#e0f2fe
style E fill:#fde68a
style I fill:#dcfce7
Lo que hace valioso este flujo es la separación de responsabilidades:
| Pieza | Responsabilidad | Lo que NO hace |
|---|---|---|
| El componente | Detectar la interacción y describir el hecho | Decidir cómo queda el estado |
| La acción | Transportar el hecho y sus datos | Contener lógica |
| El reductor | Aplicar todas las reglas de transición | Tocar el DOM, la red o el reloj |
| React | Renderizar con el estado resultante | Interpretar las acciones |
- Caso CicloUrbano:
reductorReservas al completo
reductorReservas al completoVamos con el caso real. El estado gestiona las cuatro piezas del apartado 1.
// src/reductores/reservas.js
import { DATOS_INICIALES } from '../componentes/FormularioReserva.jsx';
export const ESTADO_INICIAL_RESERVAS = {
reservas: [], // lista de Reserva
borrador: DATOS_INICIALES, // { bicicletaId, fechaInicio, horas, condiciones }
estadoEnvio: 'inactivo', // 'inactivo' | 'enviando' | 'enviado' | 'error'
error: null // mensaje de fallo, o null
};
/**
* Reductor del panel de reservas de CicloUrbano.
* Función PURA: (estadoPrevio, accion) => nuevoEstado
*/
export function reductorReservas(estado, accion) {
switch (accion.tipo) {
case 'borrador_actualizado':
return {
...estado,
borrador: { ...estado.borrador, [accion.campo]: accion.valor },
error: null // al escribir, se retira el error anterior
};
case 'borrador_reiniciado':
return { ...estado, borrador: DATOS_INICIALES, error: null };
case 'envio_iniciado':
return { ...estado, estadoEnvio: 'enviando', error: null };
case 'reserva_creada':
return {
...estado,
reservas: [...estado.reservas, accion.reserva], // la reserva llega ya construida
borrador: DATOS_INICIALES,
estadoEnvio: 'enviado',
error: null
};
case 'envio_fallido':
return { ...estado, estadoEnvio: 'error', error: accion.mensaje };
case 'reserva_confirmada':
return {
...estado,
reservas: estado.reservas.map((reserva) =>
reserva.id === accion.idReserva ? { ...reserva, estado: 'confirmada' } : reserva
)
};
case 'reserva_cancelada':
return {
...estado,
reservas: estado.reservas.map((reserva) =>
reserva.id === accion.idReserva
? { ...reserva, estado: 'cancelada', canceladaEn: accion.momento }
: reserva
)
};
case 'panel_reiniciado':
return { ...ESTADO_INICIAL_RESERVAS, reservas: estado.reservas };
default:
throw new Error(`reductorReservas: acción desconocida «${accion.tipo}»`);
}
}Comentarios sobre decisiones concretas del reductor:
- Cada
casedevuelve un objeto nuevo con...estadocomo base. Nunca se muta. Es el mismo recetario de inmutabilidad de 05-01, ahora concentrado en un solo fichero. reserva_creadarecibe la reserva ya construida. El identificadorres-${crypto.randomUUID().slice(0, 8)}y la fecha se generan en el manejador, porque son no deterministas.reserva_canceladarecibeaccion.momentopor el mismo motivo:new Date().toISOString()no puede vivir dentro del reductor.- Una acción cambia varias piezas a la vez.
reserva_creadatoca la lista, vacía el borrador, marca el envío como completado y limpia el error: cuatro cambios coherentes, imposibles de desincronizar porque ocurren en la misma expresión. panel_reiniciadoconserva las reservas y reinicia lo demás. El estado inicial se reutiliza, sin repetir literales.- El
defaultlanza un error. Es deliberado: unreturn estadosilencioso convertiría una errata como'reseva_creada'en un fallo invisible que se manifestaría como «el botón no hace nada». Con elthrow, el fallo aparece en el acto y —si has puesto unLimiteDeError(04-05)— con una interfaz alternativa decente.
El componente que lo usa
// src/componentes/PanelReservas.jsx
import { useReducer } from 'react';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from '../reductores/reservas.js';
import { validarReserva } from '../utilidades/validarReserva.js';
import Aviso from './Aviso.jsx';
import estilos from './PanelReservas.module.css';
/**
* Panel de reservas de CicloUrbano.
* Props:
* - bicicletas (array de Bicicleta, opcional, por defecto [])
* - usuarioId (cadena, opcional, por defecto 'usr-01')
*/
function PanelReservas({ bicicletas = [], usuarioId = 'usr-01' }) {
const [estado, despachar] = useReducer(reductorReservas, ESTADO_INICIAL_RESERVAS);
const { reservas, borrador, estadoEnvio, error } = estado;
// DERIVADOS: no son estado (05-01)
const errores = validarReserva(borrador, bicicletas);
const puedeEnviar = Object.keys(errores).length === 0 && estadoEnvio !== 'enviando';
function manejarCambioCampo(campo, valor) {
despachar({ tipo: 'borrador_actualizado', campo, valor });
}
async function manejarEnvio(evento) {
evento.preventDefault();
if (!puedeEnviar) return;
// Lo no determinista se calcula AQUÍ, no en el reductor
const reserva = {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId: borrador.bicicletaId,
usuario: usuarioId,
fechaInicio: borrador.fechaInicio,
horas: borrador.horas,
estado: 'activa'
};
despachar({ tipo: 'envio_iniciado' });
try {
await enviarReservaAlServidor(reserva);
despachar({ tipo: 'reserva_creada', reserva });
} catch (fallo) {
despachar({ tipo: 'envio_fallido', mensaje: fallo.message });
}
}
function manejarConfirmar(idReserva) {
despachar({ tipo: 'reserva_confirmada', idReserva });
}
function manejarCancelar(idReserva) {
despachar({
tipo: 'reserva_cancelada',
idReserva,
momento: new Date().toISOString()
});
}
return (
<section className={estilos.panel}>
<form noValidate onSubmit={manejarEnvio}>
{/* campos controlados que llaman a manejarCambioCampo (03-04) */}
<button type="submit" disabled={!puedeEnviar}>
{estadoEnvio === 'enviando' ? 'Enviando…' : 'Crear reserva'}
</button>
</form>
{estadoEnvio === 'error' && <Aviso tono="error">{error}</Aviso>}
{estadoEnvio === 'enviado' && <Aviso tono="exito">Reserva creada correctamente.</Aviso>}
<ul>
{reservas.map((reserva) => (
<li key={reserva.id}>
{reserva.id} · {reserva.horas} h · {reserva.estado}
<button type="button" onClick={() => manejarConfirmar(reserva.id)}>Confirmar</button>
<button type="button" onClick={() => manejarCancelar(reserva.id)}>Cancelar</button>
</li>
))}
</ul>
</section>
);
}
export default PanelReservas;Compara este componente con la versión de cuatro useState del apartado 1. Los manejadores han pasado de coordinar varias llamadas a anunciar un hecho en una línea. Toda la lógica de transición está en un fichero aparte que se lee de arriba abajo como el manual de funcionamiento del panel. Y manejarEnvio deja clarísimo el reparto: lo asíncrono y lo no determinista viven en el componente; lo que le pasa al estado, en el reductor.
- La tercera forma: inicialización perezosa
useReducer acepta un tercer argumento: una función que calcula el estado inicial a partir del segundo argumento.
// src/reductores/reservas.js
export function crearEstadoInicial(reservasGuardadas) {
return {
...ESTADO_INICIAL_RESERVAS,
reservas: reservasGuardadas.filter((reserva) => reserva.estado !== 'cancelada')
};
}Es la misma idea que la inicialización perezosa de useState (05-01) y aporta lo mismo: el cálculo se ejecuta una sola vez, en el primer render, en lugar de en todos. Con la ventaja añadida de que la función de inicialización, al vivir fuera del componente, también se puede probar por separado y reutilizar en una acción de reinicio:
useState frente a useReducer: tabla de decisión
useState frente a useReducer: tabla de decisión| Criterio | useState |
useReducer |
|---|---|---|
| Número de piezas de estado | Una, o varias independientes | Varias que cambian juntas |
| Complejidad de las transiciones | El nuevo valor sale de una expresión | Reglas, condiciones, varias piezas por cambio |
| Dónde vive la lógica | Repartida por los manejadores | Concentrada en una función |
| Legibilidad de los manejadores | Empeora al crecer | Una línea por manejador |
| Testabilidad | Requiere montar el componente | Función pura: se prueba sola |
| Trazabilidad | Hay que buscar quién llama a cada set |
La lista de acciones documenta lo que puede pasar |
| Estados imposibles | Posibles si no hay disciplina | Difíciles: solo existe lo que el reductor escribe |
| Dependencias de efectos | El estado suele entrar en [deps] |
despachar es estable: efectos más limpios |
| Curva de lectura | Inmediata | Hay que ir al reductor para entender el efecto de una acción |
| Líneas para un contador | 1 | ~10 |
Dos advertencias para no pasarse de frenada:
- No conviertas todo a
useReducer. Para un panel plegado/desplegado o el texto de un buscador,useStatees más corto y más claro. Un reductor para un booleano es ceremonia pura. - Puedes mezclarlos en el mismo componente. Lo habitual es un
useReducerpara el asunto complejo y algúnuseStatepara lo local y trivial.
- Probar un reductor sin React
Esta es una de las mayores ventajas prácticas y merece verse aunque las pruebas sean el Módulo 9. Un reductor es una función pura: no necesita navegador, ni DOM, ni renderizar nada.
// src/reductores/reservas.test.js (sintaxis de Vitest/Jest — Módulo 9)
import { describe, it, expect } from 'vitest';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from './reservas.js';
describe('reductorReservas', () => {
it('crea una reserva, vacía el borrador y marca el envío como completado', () => {
const reserva = {
id: 'res-02',
bicicletaId: 'bici-001',
usuario: 'usr-01',
fechaInicio: '2026-05-05T10:00',
horas: 3,
estado: 'activa'
};
const resultado = reductorReservas(
{ ...ESTADO_INICIAL_RESERVAS, estadoEnvio: 'enviando' },
{ tipo: 'reserva_creada', reserva }
);
expect(resultado.reservas).toHaveLength(1);
expect(resultado.estadoEnvio).toBe('enviado');
expect(resultado.borrador.bicicletaId).toBe('');
expect(resultado.error).toBeNull();
});
it('no muta el estado que recibe', () => {
const previo = { ...ESTADO_INICIAL_RESERVAS, reservas: [] };
reductorReservas(previo, { tipo: 'reserva_creada', reserva: { id: 'res-03' } });
expect(previo.reservas).toHaveLength(0); // el original sigue intacto
});
it('lanza ante una acción desconocida', () => {
expect(() => reductorReservas(ESTADO_INICIAL_RESERVAS, { tipo: 'inventada' })).toThrow();
});
});Sin montar un solo componente has verificado tres reglas de negocio y la inmutabilidad. Con la lógica repartida en manejadores dentro del componente, cada una de estas comprobaciones exigiría renderizar, simular clics y esperar. Es la razón principal por la que los equipos grandes extraen la lógica de estado a reductores: el código puro se prueba barato. El detalle de las herramientas llega en 09-02.
useReducer + useContext: estado compartido
useReducer + useContext: estado compartidoCombinando lo de esta lección con lo de la anterior sale un patrón muy potente: el estado complejo vive en un reductor, el reductor vive en un proveedor, y cualquier componente del árbol puede leer el estado y despachar acciones sin recibir una sola prop.
// src/contextos/ContextoReservas.jsx
import { createContext, useContext, useReducer } from 'react';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from '../reductores/reservas.js';
const ContextoReservas = createContext(null);
/**
* Proveedor del estado de reservas de CicloUrbano.
* Props:
* - children (contenido)
*/
export function ProveedorReservas({ children }) {
const [estado, despachar] = useReducer(reductorReservas, ESTADO_INICIAL_RESERVAS);
return (
<ContextoReservas value={{ estado, despachar }}>
{children}
</ContextoReservas>
);
}
export function useReservas() {
const contexto = useContext(ContextoReservas);
if (contexto === null) {
throw new Error('useReservas debe usarse dentro de <ProveedorReservas>');
}
return contexto;
}Y cualquier componente, a cualquier profundidad:
// src/componentes/ContadorReservasActivas.jsx
import { useReservas } from '../contextos/ContextoReservas.jsx';
function ContadorReservasActivas() {
const { estado } = useReservas();
const activas = estado.reservas.filter((reserva) => reserva.estado === 'activa').length;
return <span>Reservas activas: {activas}</span>;
}// src/componentes/BotonCancelarReserva.jsx
import { useReservas } from '../contextos/ContextoReservas.jsx';
function BotonCancelarReserva({ idReserva }) {
const { despachar } = useReservas();
return (
<button
type="button"
onClick={() =>
despachar({ tipo: 'reserva_cancelada', idReserva, momento: new Date().toISOString() })
}
>
Cancelar
</button>
);
}flowchart TD
PR["<ProveedorReservas><br/>useReducer(reductorReservas)"] --> DIS["Diseno"]
DIS --> CAB["Cabecera"]
CAB --> CNT["ContadorReservasActivas<br/>lee estado"]
DIS --> PAN["PanelReservas"]
PAN --> BOT["BotonCancelarReserva<br/>despacha acciones"]
BOT -. "despachar" .-> PR
PR -. "estado" .-> CNT
style PR fill:#fde68a
style CNT fill:#dcfce7
style BOT fill:#e0f2fe
Si has oído hablar de Redux, acabas de escribir su modelo mental completo: un estado centralizado, acciones que describen hechos y reductores puros que calculan el estado siguiente. Redux añade herramientas alrededor —un almacén único fuera de React, middleware para lo asíncrono, extensiones de depuración que rebobinan acciones, y utilidades como Redux Toolkit—, pero el núcleo es exactamente esto. La comparación completa y cuándo compensa cada opción se ve en 07-01 y 07-03. Aquí lo importante es que ya entiendes el mecanismo, así que Redux no te resultará un misterio sino un empaquetado de algo conocido.
Un apunte de rendimiento, en la misma línea que el de 05-04: al meter { estado, despachar } en un contexto, cualquier cambio de estado repinta a todos los consumidores, incluidos los que solo despachan y nunca leen. La solución habitual —separar el estado y el despachador en dos contextos— pertenece a 07-02.
Errores Comunes y Consejos
- Mutar el estado dentro del reductor.
estado.reservas.push(nueva); return estado;no repinta: la referencia es la misma. Copia siempre. - Meter código no determinista o efectos en el reductor.
Date.now(),crypto.randomUUID(),fetcholocalStoragerompen la pureza. Calcula en el manejador y pásalo en la acción. - Un
defaultque devuelve el estado en silencio. Convierte una errata en un botón que no hace nada. Lanza un error con el tipo recibido. - Nombrar las acciones en imperativo (
poner_error,set_reservas). La lógica vuelve al componente y pierdes toda la ventaja. - Declarar el reductor dentro del componente. Se recrea en cada render, no se puede probar por separado y sugiere que depende del ámbito, cosa que no debe.
- Convertir a
useReducerun estado simple. Diez líneas para un booleano no mejoran nada. - Leer el estado justo después de despachar. Sigue vigente la instantánea de 05-01:
despacharno cambiaestadoen el render en curso. - Olvidar
...estadoal devolver.return { reservas: [...] };borra el borrador, el estado de envío y el error de un plumazo. - Consejo: escribe primero la lista de acciones, antes que el reductor. Si esa lista se lee como la crónica de lo que puede ocurrir en la pantalla, el diseño es bueno.
- Consejo: si un
casese repite casi igual que otro, extrae una función auxiliar pura y llámala desde ambos. El reductor puede apoyarse en otras funciones puras sin problema.
Ejercicios
Ejercicio 1. Este reductor de CicloUrbano tiene cuatro fallos. Encuéntralos, explica el problema de cada uno y escribe la versión corregida.
function reductorFlota(estado, accion) {
switch (accion.tipo) {
case 'bicicleta_añadida':
estado.bicicletas.push({ id: `bici-${Math.random()}`, ...accion.datos });
return estado;
case 'bicicleta_a_taller':
return {
bicicletas: estado.bicicletas.map((b) =>
b.id === accion.id ? { ...b, estado: 'mantenimiento' } : b
)
};
case 'flota_recargada':
fetch('/api/bicicletas').then((r) => r.json()).then((d) => (estado.bicicletas = d));
return estado;
default:
return estado;
}
}Ejercicio 2. Convierte a useReducer este componente de CicloUrbano. Define el estado inicial, escribe el reductor completo con nombres de acción declarativos y reescribe los manejadores.
function SelectorReserva({ bicicletas }) {
const [idBicicleta, setIdBicicleta] = useState(null);
const [horas, setHoras] = useState(2);
const [confirmada, setConfirmada] = useState(false);
const [error, setError] = useState(null);
function manejarSeleccion(id) {
setIdBicicleta(id);
setHoras(2);
setConfirmada(false);
setError(null);
}
function manejarCambioHoras(nuevas) {
if (nuevas < 1 || nuevas > 24) {
setError('Las reservas van de 1 a 24 horas.');
return;
}
setHoras(nuevas);
setError(null);
setConfirmada(false);
}
function manejarConfirmar() {
if (!idBicicleta) {
setError('Elige una bicicleta antes de confirmar.');
return;
}
setConfirmada(true);
setError(null);
}
…
}Ejercicio 3. Escribe tres pruebas del reductorReservas del apartado 7, sin renderizar nada: que borrador_actualizado cambia solo el campo indicado y conserva los demás; que envio_fallido guarda el mensaje y deja las reservas intactas; y que panel_reiniciado conserva la lista de reservas pero vacía el borrador y el error.
Soluciones
Solución 1.
Los cuatro fallos:
bicicleta_añadidamuta el estado conpushy devuelve la misma referencia. React no ve ningún cambio y no repinta.Math.random()dentro del reductor rompe el determinismo. El identificador debe generarse en el manejador y llegar en la acción.bicicleta_a_tallerno copia el resto del estado. Devuelve un objeto con solobicicletas, así que cualquier otra pieza (filtros, selección, error) se pierde.flota_recargadahacefetchy muta el estado en elthen. Un efecto secundario dentro del reductor, además asíncrono: cuando la respuesta llegue, ese objeto de estado hará mucho que se descartó. La carga de datos va en un efecto (05-02) o en el manejador, y el resultado se despacha como acción.
Añadido: el default que devuelve el estado en silencio oculta erratas en los tipos.
export const ESTADO_INICIAL_FLOTA = { bicicletas: [], cargando: false, error: null };
export function reductorFlota(estado, accion) {
switch (accion.tipo) {
case 'bicicleta_añadida':
// La bicicleta llega ya construida, con su id generado fuera
return { ...estado, bicicletas: [...estado.bicicletas, accion.bicicleta] };
case 'bicicleta_a_taller':
return {
...estado,
bicicletas: estado.bicicletas.map((bicicleta) =>
bicicleta.id === accion.id ? { ...bicicleta, estado: 'mantenimiento' } : bicicleta
)
};
case 'recarga_iniciada':
return { ...estado, cargando: true, error: null };
case 'flota_recargada':
// El fetch lo hace el componente; aquí solo llegan los datos
return { ...estado, bicicletas: accion.bicicletas, cargando: false, error: null };
case 'recarga_fallida':
return { ...estado, cargando: false, error: accion.mensaje };
default:
throw new Error(`reductorFlota: acción desconocida «${accion.tipo}»`);
}
}Y el manejador, con lo asíncrono y lo no determinista donde corresponde:
function manejarAñadir(datos) {
despachar({
tipo: 'bicicleta_añadida',
bicicleta: { id: `bici-${crypto.randomUUID().slice(0, 8)}`, ...datos }
});
}
async function manejarRecargar() {
despachar({ tipo: 'recarga_iniciada' });
try {
const respuesta = await fetch('/api/bicicletas');
if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
despachar({ tipo: 'flota_recargada', bicicletas: await respuesta.json() });
} catch (fallo) {
despachar({ tipo: 'recarga_fallida', mensaje: fallo.message });
}
}Solución 2.
// src/reductores/selectorReserva.js
export const ESTADO_INICIAL_SELECTOR = {
idBicicleta: null,
horas: 2,
confirmada: false,
error: null
};
const HORAS_MINIMAS = 1;
const HORAS_MAXIMAS = 24;
export function reductorSelector(estado, accion) {
switch (accion.tipo) {
case 'bicicleta_elegida':
// Elegir bicicleta reinicia toda la reserva en curso
return { ...ESTADO_INICIAL_SELECTOR, idBicicleta: accion.id };
case 'horas_cambiadas':
if (accion.horas < HORAS_MINIMAS || accion.horas > HORAS_MAXIMAS) {
return {
...estado,
error: `Las reservas van de ${HORAS_MINIMAS} a ${HORAS_MAXIMAS} horas.`
};
}
return { ...estado, horas: accion.horas, error: null, confirmada: false };
case 'reserva_confirmada':
if (!estado.idBicicleta) {
return { ...estado, error: 'Elige una bicicleta antes de confirmar.' };
}
return { ...estado, confirmada: true, error: null };
default:
throw new Error(`reductorSelector: acción desconocida «${accion.tipo}»`);
}
}// src/componentes/SelectorReserva.jsx
import { useReducer } from 'react';
import { reductorSelector, ESTADO_INICIAL_SELECTOR } from '../reductores/selectorReserva.js';
function SelectorReserva({ bicicletas = [] }) {
const [estado, despachar] = useReducer(reductorSelector, ESTADO_INICIAL_SELECTOR);
const { idBicicleta, horas, confirmada, error } = estado;
function manejarSeleccion(id) {
despachar({ tipo: 'bicicleta_elegida', id });
}
function manejarCambioHoras(nuevas) {
despachar({ tipo: 'horas_cambiadas', horas: nuevas });
}
function manejarConfirmar() {
despachar({ tipo: 'reserva_confirmada' });
}
…
}Lo que se ha ganado: los tres manejadores son de una línea; las reglas —los límites de 1 a 24 horas, que elegir bicicleta reinicia la reserva, que confirmar sin bicicleta es un error— están escritas en un solo fichero y se leen seguidas; los límites son constantes con nombre en lugar de números sueltos; y bicicleta_elegida reutiliza ESTADO_INICIAL_SELECTOR en vez de repetir cuatro asignaciones, de modo que si mañana se añade una quinta pieza al estado, el reinicio la incluye sin tocar nada.
Solución 3.
import { describe, it, expect } from 'vitest';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from './reservas.js';
describe('reductorReservas', () => {
it('borrador_actualizado cambia solo el campo indicado', () => {
const previo = {
...ESTADO_INICIAL_RESERVAS,
borrador: { bicicletaId: 'bici-001', fechaInicio: '2026-05-04T09:00', horas: 2, condiciones: false }
};
const resultado = reductorReservas(previo, {
tipo: 'borrador_actualizado',
campo: 'horas',
valor: 5
});
expect(resultado.borrador.horas).toBe(5);
expect(resultado.borrador.bicicletaId).toBe('bici-001'); // los demás intactos
expect(resultado.borrador.fechaInicio).toBe('2026-05-04T09:00');
expect(previo.borrador.horas).toBe(2); // sin mutación
});
it('envio_fallido guarda el mensaje y no toca las reservas', () => {
const previo = {
...ESTADO_INICIAL_RESERVAS,
reservas: [{ id: 'res-01', horas: 2, estado: 'activa' }],
estadoEnvio: 'enviando'
};
const resultado = reductorReservas(previo, {
tipo: 'envio_fallido',
mensaje: 'El servidor respondió 503'
});
expect(resultado.estadoEnvio).toBe('error');
expect(resultado.error).toBe('El servidor respondió 503');
expect(resultado.reservas).toBe(previo.reservas); // misma referencia: no se ha copiado
});
it('panel_reiniciado conserva las reservas y limpia lo demás', () => {
const previo = {
reservas: [{ id: 'res-01', horas: 2, estado: 'activa' }],
borrador: { bicicletaId: 'bici-003', fechaInicio: '2026-05-06T08:00', horas: 6, condiciones: true },
estadoEnvio: 'error',
error: 'Fallo anterior'
};
const resultado = reductorReservas(previo, { tipo: 'panel_reiniciado' });
expect(resultado.reservas).toHaveLength(1);
expect(resultado.borrador.bicicletaId).toBe('');
expect(resultado.estadoEnvio).toBe('inactivo');
expect(resultado.error).toBeNull();
});
});Fíjate en la segunda prueba: expect(resultado.reservas).toBe(previo.reservas) comprueba la identidad, no la igualdad. Es una afirmación deliberada: envio_fallido no debe copiar la lista, porque copiarla sin necesidad generaría una referencia nueva y obligaría a repintar a todos los componentes memorizados que dependen de ella (Módulo 8). Un reductor bien escrito conserva las referencias de lo que no cambia.
Conclusión
useReducer es la respuesta cuando el estado deja de ser un dato y pasa a ser un sistema: varias piezas que cambian juntas, transiciones con reglas y manejadores que se vuelven ilegibles. La idea central es un cambio de responsabilidades: el componente ya no decide cómo queda el estado, solo despacha acciones que describen lo que ha ocurrido, y una función pura —el reductor— concentra todas las reglas. Has visto la firma useReducer(reductor, estadoInicial) y su tercera forma con inicialización perezosa; la anatomía de las acciones y por qué nombrarlas por el hecho (reserva_confirmada) y no por la operación (poner_estado) es lo que evita que la lógica se vuelva a dispersar; que despachar es estable y con ello simplifica dependencias de efectos y memorización; el reductorReservas completo de CicloUrbano con su switch, su inmutabilidad y su default que lanza; y dos ventajas que se cobran a diario: un reductor se prueba sin React, por ser puro, y se combina con useContext para dar a todo el árbol acceso al estado y a las acciones. Ese último patrón es, literalmente, el modelo mental de Redux, que el Módulo 7 retomará con su comparación completa.
Con esto has recorrido los cinco hooks fundamentales de React: useState, useEffect, useRef, useContext y useReducer. Pero queda la pieza que da sentido a todo el diseño de los hooks y que 04-04 anunció como su razón de ser histórica: reutilizar lógica con estado sin envoltorios. El buscador con retardo del ejercicio de 05-02, la suscripción al evento de conexión, el temporizador de disponibilidad, el estado sincronizado con localStorage del tema… todo eso son piezas que se repiten en distintos componentes y que hoy tendrías que copiar y pegar. La próxima lección convierte esa lógica en funciones reutilizables que se llaman igual que los hooks de React porque son hooks. La próxima lección es Hooks Personalizados.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
