Módulo 11: useReducer y Context
Objetivos del módulo
- Reconocer cuándo el estado de un componente ha crecido tanto que
useStatese queda corto - Escribir reductores y entender por qué
useReducersepara qué ha pasado de cómo cambia el estado - Saber qué ocurre por dentro: cola de acciones, momento en que se ejecuta el reductor y bail out
- Mantener puros los reductores y entender por qué Strict Mode destapa las mutaciones
- Tipar las acciones como uniones discriminadas y comprobar en compilación que no falta ninguna
- Modelar cargas de datos como máquinas de estados, y probar reductores sin montar componentes
- Evitar pasar props en cadena con Context:
createContext, el proveedor y sus consumidores - Usar
<Context>como proveedor y leer conuse(Contexto), y saber qué hacer en versiones anteriores - Construir un contexto seguro con
nullcomo valor inicial y un hook propio que avise si falta el proveedor - Combinar reductor y contexto para compartir estado complejo en toda una rama de la aplicación
- Medir cuántos renders provoca un contexto y reducirlos con
useMemoo separando contextos - Decidir cuándo no usar Context
⚠️ Importante:
useReducer,createContextyuseContextfuncionan igual en React 18.3. Este módulo usa dos formas de escribir que aparecieron en React 19.0, y si tienes una versión anterior debes hacer dos sustituciones en todos los ejemplos:<Contexto value={x}>pasa a ser<Contexto.Provider value={x}>, yuse(Contexto)pasa a seruseContext(Contexto), siempre al principio del componente. Las secciones 10 y 11 lo explican con detalle.
1. Cuando useState se queda corto
En el módulo 04 guardaste el estado con useState. Es perfecto para datos sencillos e independientes. Pero piensa en la cesta de una tienda: añadir un producto, quitarlo, cambiar la cantidad, respetar el stock, vaciarla. Con useState, cada una de esas reglas acaba repartida en un manejador distinto:
// Funciona, pero las reglas de la cesta están repartidas por el componente
function anadir(producto: Producto) {
const existente = lineas.find((l) => l.producto.productoId === producto.productoId);
if (existente) {
setLineas(lineas.map((l) =>
l === existente ? { ...l, cantidad: Math.min(l.cantidad + 1, producto.stock) } : l,
));
} else {
setLineas([...lineas, { producto, cantidad: 1 }]);
}
}
function cambiarCantidad(productoId: number, cantidad: number) { /* otra versión del límite */ }
function quitar(productoId: number) { /* ... */ }
function vaciar() { /* ... */ }
Este código tiene varios problemas, y crecen con la aplicación:
- La lógica se mezcla con la interfaz. Para saber cómo funciona la cesta tienes que leer un componente entero.
- Las reglas se repiten. El límite de stock aparece en dos sitios, y un día dejarán de coincidir.
- Es difícil de probar. Para comprobar una regla hay que montar el componente y simular clics.
- Varios
useStaterelacionados pueden contradecirse. Sicargando,errorydatosson tres estados distintos, nada impide quecargandoyerrorestén activos a la vez.
La idea de useReducer es sacar todas las transiciones a una única función. Los manejadores ya no dicen cómo cambia el estado, solo informan de lo que ha pasado:
// Con un reductor, el manejador solo cuenta qué ha ocurrido
dispatch({ type: 'producto_anadido', producto });
📘 Concepto: Un reductor (reducer) es una función pura que recibe el estado actual y una acción (un objeto que describe lo ocurrido), y devuelve el estado siguiente:
(estado, accion) => estadoNuevo.
De dónde viene el nombre
El nombre viene de Array.prototype.reduce, que quizá conozcas del Curso de JavaScript. reduce recibe una función que combina el acumulado con cada elemento. Si los elementos son acciones y el acumulado es el estado, tienes exactamente un reductor:
function reductor(saldo: number, movimiento: number) {
return saldo + movimiento;
}
[100, -30, 50].reduce(reductor, 0); // 120
Un componente con useReducer hace lo mismo, salvo que las acciones no están en un array: van llegando con el tiempo, una por cada cosa que hace el usuario. El estado actual es el resultado de aplicar, en orden, todas las acciones desde el estado inicial.
Prepara el proyecto
Para probar los ejemplos crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-11 -- --template react-ts
cd modulo-11
npm install
npm run dev
Cada ejemplo completo sustituye a src/App.tsx, salvo que se indiquen más ficheros. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 640px;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.separada {
justify-content: space-between;
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
align-items: start;
}
.rejilla .tarjeta {
margin: 0;
}
button {
font: inherit;
padding: 0.35rem 0.75rem;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #e8eaf6;
color: #1a237e;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input,
select {
font: inherit;
padding: 0.35rem 0.5rem;
border: 1px solid #9fa8da;
border-radius: 6px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #e8eaf6;
}
.dato {
font-size: 1.8rem;
font-weight: 700;
color: #1a237e;
}
.alerta {
color: #bf360c;
font-weight: 600;
}
.tenue {
color: #607d8b;
}
.insignia {
background: #ff7043;
color: #fff;
border-radius: 999px;
padding: 0 0.55rem;
font-weight: 700;
}
.panel {
border-radius: 10px;
padding: 0.75rem 1rem;
margin: 0.75rem 0;
border: 1px solid #c5cae9;
}
.panel-claro {
background: #fff;
color: #263238;
}
.panel-oscuro {
background: #1a237e;
color: #e8eaf6;
}
.boton-oscuro {
background: #3949ab;
border-color: #c5cae9;
color: #fff;
}
Varios ejemplos usan la API EMPRESA. Descarga recursos/empresa-api.zip, descomprímelo y arráncalo en otra terminal con node server.mjs. Quedará en http://localhost:3000/api.
2. useReducer paso a paso
useReducer recibe el reductor y el estado inicial, y devuelve el estado actual y una función para enviar acciones:
const [estado, dispatch] = useReducer(reductor, estadoInicial);
Este primer ejemplo controla el stock de un producto:
import { useReducer } from 'react';
type Estado = { unidades: number; minimo: number };
type Accion =
| { type: 'entrada'; cantidad: number }
| { type: 'salida'; cantidad: number }
| { type: 'reiniciar' };
const estadoInicial: Estado = { unidades: 10, minimo: 3 };
function stockReducer(estado: Estado, accion: Accion): Estado {
switch (accion.type) {
case 'entrada':
return { ...estado, unidades: estado.unidades + accion.cantidad };
case 'salida':
return { ...estado, unidades: Math.max(0, estado.unidades - accion.cantidad) };
case 'reiniciar':
return estadoInicial;
}
}
export default function App() {
const [estado, dispatch] = useReducer(stockReducer, estadoInicial);
return (
<section className="tarjeta">
<h2>Stock de ratones inalámbricos</h2>
<p className="dato">{estado.unidades} uds.</p>
{estado.unidades < estado.minimo && <p className="alerta">Por debajo del mínimo</p>}
<div className="fila">
<button onClick={() => dispatch({ type: 'entrada', cantidad: 5 })}>Entrada de 5</button>
<button onClick={() => dispatch({ type: 'salida', cantidad: 4 })}>Salida de 4</button>
<button onClick={() => dispatch({ type: 'reiniciar' })}>Reiniciar</button>
</div>
</section>
);
}

Figura 1. El ejemplo tras pulsar dos veces “Salida de 4”. La regla que impide bajar de cero está en el reductor, no en el botón.
Las piezas son siempre las mismas:
| Pieza | Qué es | En el ejemplo |
|---|---|---|
| Estado | El dato que recuerda el componente | { unidades, minimo } |
| Acción | Objeto que describe lo ocurrido; por convenio, con un campo type | { type: 'salida', cantidad: 4 } |
| Reductor | Calcula el estado siguiente | stockReducer |
dispatch | Envía una acción a React | dispatch({ type: 'reiniciar' }) |
Fíjate en lo que no hace el botón “Salida de 4”: no calcula nada ni conoce el Math.max. Solo informa. Si mañana la regla cambia, se toca una sola función.
El campo type no es obligatorio para React, que trata la acción como un valor cualquiera: podrías enviar un número o una cadena. Se usa un objeto con type porque es el convenio del ecosistema y porque TypeScript le saca mucho partido, como verás en la sección 5.
Figura 2. Un viaje completo. Las acciones siempre circulan en un sentido: del evento al reductor, y del reductor a la pantalla.
Inicialización perezosa
El estado inicial se evalúa en cada render aunque solo se use en el primero. Si calcularlo es caro, por ejemplo porque lee localStorage, pasa una función inicializadora como tercer argumento. React la llama una sola vez, con el segundo argumento:
function leerGuardado(clave: string): Estado {
const texto = localStorage.getItem(clave);
return texto ? (JSON.parse(texto) as Estado) : { total: 0 };
}
const [estado, dispatch] = useReducer(totalReducer, 'cesta', leerGuardado);
⚠️ Importante: Escribe
leerGuardado, sin paréntesis. ConuseReducer(totalReducer, leerGuardado('cesta'))la función se ejecutaría en cada render. Es el mismo error que conuseState.
Un reductor sin acciones
Si el reductor solo declara el parámetro del estado, dispatch no recibe argumentos. Resulta muy cómodo para un interruptor:
const [activo, alternar] = useReducer((actual: boolean) => !actual, false);
return <button onClick={alternar}>{activo ? 'Sí' : 'No'}</button>;
3. Qué pasa por dentro
Ejecuta este ejemplo con la consola abierta. Sirve para ver cuándo se llama al reductor, algo que suele sorprender:
import { useReducer } from 'react';
type Accion = { type: 'sumar' } | { type: 'nada' };
function reducer(estado: number, accion: Accion): number {
console.log(` reducer(${estado}, ${accion.type})`);
return accion.type === 'sumar' ? estado + 1 : estado;
}
function Hijo() {
console.log(' render de Hijo');
return <p className="tenue">Soy un hijo sin props</p>;
}
export default function App() {
const [n, dispatch] = useReducer(reducer, 0);
console.log(`render de App: n = ${n}`);
function sumarTres() {
dispatch({ type: 'sumar' });
dispatch({ type: 'sumar' });
dispatch({ type: 'sumar' });
console.log(`fin del manejador: n sigue siendo ${n}`);
}
return (
<section className="tarjeta">
<h2>Valor: {n}</h2>
<Hijo />
<div className="fila">
<button onClick={sumarTres}>Sumar tres</button>
<button onClick={() => dispatch({ type: 'nada' })}>Acción sin efecto</button>
</div>
</section>
);
}
Esta es la salida real al pulsar “Sumar tres”. main.tsx activa <StrictMode>, y por eso cada llamada sale dos veces:
fin del manejador: n sigue siendo 0
reducer(0, sumar)
reducer(0, sumar)
reducer(1, sumar)
reducer(1, sumar)
reducer(2, sumar)
reducer(2, sumar)
render de App: n = 3
render de App: n = 3
render de Hijo
render de Hijo
Saca cuatro conclusiones de estas líneas:
dispatchno ejecuta el reductor. La primera línea es la del final del manejador, y todavía no se ha llamado al reductor ni una vez.dispatchsolo deja la acción en una cola y pide un render.- El reductor se ejecuta durante el render siguiente. Es parte de renderizar, y por eso debe ser puro.
- El estado es una instantánea. Dentro del manejador,
nvale 0 antes y después de losdispatch, como conuseState(módulo 05). Si necesitas el valor siguiente, calcúlalo antes o muévelo al reductor. - Las acciones van por lotes y encadenadas. Las tres acciones provocan un solo render, y cada una recibe el resultado de la anterior: 0, 1, 2. Un reductor se comporta como la función actualizadora
setN((n) => n + 1), sin que tengas que hacer nada.
💡 Consejo: Probado también en React 18.3.1. Solo cambia el orden de las líneas duplicadas por Strict Mode: allí se intercalan el reductor y el render (
reducer,render,reducer,render). No afecta a tu código, porque un reductor puro da el mismo resultado.
Devolver el mismo estado
¿Y si el reductor devuelve exactamente el estado que recibió? Esta es la salida real de pulsar “Acción sin efecto”, y es igual tanto la primera vez como las siguientes:
reducer(3, nada)
reducer(3, nada)
render de App: n = 3
render de App: n = 3
App se ha llamado, porque React necesita renderizarla para ejecutar el reductor, pero Hijo no aparece. Como el estado es idéntico según Object.is, React abandona el trabajo: no sigue hacia los hijos, no ejecuta efectos y no toca el DOM. Es lo que se llama bail out.
Esto explica una buena práctica: si una acción no cambia nada, devuelve el mismo objeto y no una copia.
// ✅ Mismo objeto: React abandona el trabajo
if (pedido.estado !== 'PENDIENTE') return pedido;
// ❌ Copia idéntica por dentro, pero objeto distinto: también se renderizan los hijos
if (pedido.estado !== 'PENDIENTE') return { ...pedido };
dispatch es estable
La función dispatch es la misma en todos los renders de un componente. Lo hemos comprobado guardándola en un Set durante varios renders: solo contiene una identidad, tanto en 18.3.1 como en 19.3.1. Tiene dos consecuencias prácticas: no hace falta incluirla en las dependencias de useEffect para que el efecto funcione bien (aunque incluirla es inofensivo), y se puede pasar a hijos sin romper memo. La sección 13 lo aprovecha.
4. Los reductores deben ser puros
Un reductor es parte del render, así que debe cumplir las reglas de pureza que viste en el módulo 03:
- Mismas entradas, misma salida. Nada de
Math.random(),Date.now()nicrypto.randomUUID()dentro. - Sin efectos secundarios. Nada de
fetch,localStorage,console.logen producción, ni temporizadores. - Sin mutar. Ni el estado ni la acción: se devuelven datos nuevos.
La última regla se viola a menudo sin querer. Este ejemplo enfrenta dos reductores que parecen equivalentes:
import { useReducer } from 'react';
type Estado = { lineas: string[] };
type Accion = { type: 'linea_anadida'; texto: string };
// ❌ MAL: modifica el estado que recibe
function reducerMutante(estado: Estado, accion: Accion): Estado {
estado.lineas.push(accion.texto);
return { ...estado };
}
// ✅ BIEN: devuelve datos nuevos y no toca los recibidos
function reducerPuro(estado: Estado, accion: Accion): Estado {
return { ...estado, lineas: [...estado.lineas, accion.texto] };
}
type Props = { titulo: string; reducer: (estado: Estado, accion: Accion) => Estado };
function Pedido({ titulo, reducer }: Props) {
const [estado, dispatch] = useReducer(reducer, { lineas: [] });
return (
<section className="tarjeta">
<h2>{titulo}</h2>
<button onClick={() => dispatch({ type: 'linea_anadida', texto: 'Teclado mecánico' })}>
Añadir una línea
</button>
<p>
Líneas: <strong>{estado.lineas.length}</strong>
</p>
<ol>
{estado.lineas.map((linea, i) => (
<li key={i}>{linea}</li>
))}
</ol>
</section>
);
}
export default function App() {
return (
<div className="rejilla">
<Pedido titulo="Reductor que muta" reducer={reducerMutante} />
<Pedido titulo="Reductor puro" reducer={reducerPuro} />
</div>
);
}

Figura 3. Un clic en cada tarjeta. El reductor que muta añade la línea dos veces.
¿Por qué dos líneas? En desarrollo, Strict Mode llama al reductor dos veces con el mismo estado de partida para destapar impurezas. La versión pura calcula dos veces el mismo resultado y React se queda con uno. La mutante hace push dos veces sobre el mismo array, que queda modificado para siempre.
⚠️ Importante: No es un fallo de Strict Mode. Te está enseñando un error que ya tenías. Sin Strict Mode verías una sola línea, pero el código seguiría roto de un modo más difícil de detectar: las acciones se pueden reprocesar en renders concurrentes (módulo 15), y cualquier comparación con el estado anterior fallaría, porque el anterior también ha cambiado.
Lo impuro va en el manejador, y entra en la acción
Si una acción necesita un identificador o una fecha, genéralos antes del dispatch y mételos en la acción. Así el reductor sigue siendo una función pura y fácil de probar:
// ❌ MAL: el reductor da un resultado distinto cada vez
case 'nota_anadida':
return [...notas, { id: crypto.randomUUID(), texto: accion.texto, fecha: Date.now() }];
// ✅ BIEN: lo impuro ocurre en el manejador
function anadirNota(texto: string) {
dispatch({ type: 'nota_anadida', id: crypto.randomUUID(), texto, fecha: Date.now() });
}
Actualizaciones inmutables habituales
| Operación | Forma inmutable |
|---|---|
| Cambiar un campo | { ...estado, unidades: 3 } |
| Añadir a un array | [...lista, nuevo] |
| Quitar de un array | lista.filter((x) => x.id !== id) |
| Cambiar un elemento | lista.map((x) => (x.id === id ? { ...x, activo: true } : x)) |
| Cambiar un campo anidado | { ...estado, datos: { ...estado.datos, ciudad } } |
Son las mismas formas del módulo 04. Con el reductor quedan todas en un solo sitio.
5. Acciones tipadas
La ventaja real de un reductor con TypeScript es tipar las acciones como una unión discriminada: un tipo con varias formas que se distinguen por un campo común. TypeScript usa ese campo para saber qué datos lleva cada acción:
type Accion =
| { type: 'entrada'; cantidad: number }
| { type: 'salida'; cantidad: number }
| { type: 'reiniciar' };
function stockReducer(estado: Estado, accion: Accion): Estado {
switch (accion.type) {
case 'entrada':
// Aquí TypeScript sabe que accion tiene cantidad
return { ...estado, unidades: estado.unidades + accion.cantidad };
case 'reiniciar':
// Aquí accion.cantidad sería un error de compilación
return estadoInicial;
// ...
}
}
Las erratas se descubren al compilar. Estos son los errores reales de TypeScript 7 con dos llamadas equivocadas:
dispatch({ type: 'sumr', cantidad: 1 });
dispatch({ type: 'sumar' });
error TS2820: Type '"sumr"' is not assignable to type '"cero" | "restar" | "sumar"'. Did you mean '"sumar"'?
error TS2345: Argument of type '{ type: "sumar"; }' is not assignable to parameter of type 'Accion'.
Property 'cantidad' is missing in type '{ type: "sumar"; }' but required in type '{ type: "sumar"; cantidad: number; }'.
El módulo 13 profundiza en las uniones discriminadas.
Comprobar que no falta ningún caso
Si añades una acción al tipo y olvidas tratarla, ¿qué ocurre? Hay dos formas de que el compilador te avise. La primera es declarar el tipo de retorno y no poner default: si falta un caso, la función puede acabar sin devolver nada.
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
La segunda es más explícita y además protege en ejecución. En el default, el tipo de accion debe haber quedado reducido a never, el tipo sin valores posibles:
default: {
const imposible: never = accion;
throw new Error(`Acción desconocida: ${JSON.stringify(imposible)}`);
}
Con una acción restar sin tratar, el error lo señala por su nombre:
error TS2322: Type '{ type: "restar"; cantidad: number; }' is not assignable to type 'never'.
⚠️ Importante: No te olvides del
returnen ninguna rama. En JavaScript, o si escribes el reductor sin tipos, una acción no tratada hace que la función devuelvaundefined, y React lo acepta sin ningún aviso: lo hemos comprobado en 18.3.1, 19.0.9 y 19.3.1. Tu estado pasa a serundefinedy el fallo aparece más tarde en otro sitio, al leer una propiedad.
Nombra las acciones por lo que ha pasado
Una acción describe un hecho, no una orden de bajo nivel. Si todas tus acciones parecen setters, has vuelto a useState con más líneas:
| ❌ Acción como setter | ✅ Acción como hecho |
|---|---|
{ type: 'set_lineas', lineas } | { type: 'producto_anadido', producto } |
{ type: 'set_cargando', valor: true } | { type: 'peticion_iniciada' } |
{ type: 'set_error', error } | { type: 'peticion_fallida', mensaje } |
Con hechos, la lógica vive en el reductor, y una sola acción puede cambiar varios campos a la vez sin dejarlos incoherentes. En este módulo usamos el formato sustantivo_participio. Otros proyectos prefieren cesta/anadir, que es el que verás en Redux Toolkit (módulo 20). Lo importante es ser constante.
Tipos y genéricos de useReducer 🔄 19.0
🔄 Cambia en React 19.0. Los tipos de
@types/react19 cambian los genéricos deuseReducer: ahora sonuseReducer<Estado, [Accion]>, y ya nouseReducer<Reducer<Estado, Accion>>. Si usas una versión anterior: deja que TypeScript infiera los tipos desde el reductor, que funciona igual en ambas versiones. Si necesitas escribirlos a mano con@types/react18, usauseReducer<Reducer<Estado, Accion>>, yDispatch<Accion>para tipardispatch, porqueActionDispatchno existe en esos tipos.
Casi nunca tendrás que escribir genéricos: si anotas los parámetros del reductor, se infiere todo. Pero si copias código antiguo, con @types/react 19.3.0 obtendrás este error:
const [, dispatch] = useReducer<Reducer<Estado, Accion>>(reductor, { n: 0 });
error TS2558: Expected 2 type arguments, but got 1.
Los tipos actuales son estos, y el segundo genérico es una tupla con los argumentos de la acción:
import { useReducer } from 'react';
import type { ActionDispatch, Dispatch, Reducer } from 'react';
type Estado = { total: number };
type Accion = { type: 'sumar'; cantidad: number } | { type: 'cero' };
// Reducer<S, A> sirve para anotar la función entera
const totalReducer: Reducer<Estado, Accion> = (estado, accion) =>
accion.type === 'sumar' ? { total: estado.total + accion.cantidad } : { total: 0 };
// Si te sobra un genérico, en React 19 son S y una tupla con los argumentos de la acción
export function Explicito() {
const [estado, dispatch] = useReducer<Estado, [Accion]>(totalReducer, { total: 0 });
return <button onClick={() => dispatch({ type: 'cero' })}>{estado.total}</button>;
}
// Tres formas equivalentes de tipar el dispatch que recibe un hijo
export type D1 = Dispatch<Accion>;
export type D2 = ActionDispatch<[Accion]>;
export type D3 = (accion: Accion) => void;
export function Hijo({ dispatch }: { dispatch: D1 }) {
return <button onClick={() => dispatch({ type: 'cero' })}>Cero</button>;
}
export function Padre() {
const [, dispatch] = useReducer(totalReducer, { total: 0 });
const otro: D2 = dispatch;
const y: D3 = otro;
return <Hijo dispatch={y} />;
}
// Reductor sin acción: dispatch no recibe argumentos
export function SinAccion() {
const [activo, alternar] = useReducer((actual: boolean) => !actual, false);
return <button onClick={alternar}>{activo ? 'Sí' : 'No'}</button>;
}
// Inicializador perezoso: el segundo argumento se pasa al tercero
function leerGuardado(clave: string): Estado {
const texto = localStorage.getItem(clave);
return texto ? (JSON.parse(texto) as Estado) : { total: 0 };
}
export function Perezoso() {
const [estado] = useReducer(totalReducer, 'cesta', leerGuardado);
return <p>{estado.total}</p>;
}
6. Caso real: la cesta de un pedido
Ya puedes escribir la cesta del principio. El reductor va en su propio fichero, sin nada de React. Crea src/cesta/cestaReducer.ts:
export type Producto = {
productoId: number;
nombre: string;
precio: number;
stock: number;
};
export type LineaCesta = { producto: Producto; cantidad: number };
export type EstadoCesta = { lineas: LineaCesta[] };
export type AccionCesta =
| { type: 'producto_anadido'; producto: Producto }
| { type: 'producto_quitado'; productoId: number }
| { type: 'cantidad_cambiada'; productoId: number; cantidad: number }
| { type: 'cesta_vaciada' };
export const cestaVacia: EstadoCesta = { lineas: [] };
export function cestaReducer(estado: EstadoCesta, accion: AccionCesta): EstadoCesta {
switch (accion.type) {
case 'producto_anadido': {
const { producto } = accion;
const existente = estado.lineas.find((l) => l.producto.productoId === producto.productoId);
if (!existente) {
return { ...estado, lineas: [...estado.lineas, { producto, cantidad: 1 }] };
}
return cestaReducer(estado, {
type: 'cantidad_cambiada',
productoId: producto.productoId,
cantidad: existente.cantidad + 1,
});
}
case 'producto_quitado':
return {
...estado,
lineas: estado.lineas.filter((l) => l.producto.productoId !== accion.productoId),
};
case 'cantidad_cambiada': {
if (accion.cantidad <= 0) {
return cestaReducer(estado, { type: 'producto_quitado', productoId: accion.productoId });
}
return {
...estado,
lineas: estado.lineas.map((l) =>
l.producto.productoId === accion.productoId
? { ...l, cantidad: Math.min(accion.cantidad, l.producto.stock) }
: l,
),
};
}
case 'cesta_vaciada':
return cestaVacia;
default: {
const imposible: never = accion;
throw new Error(`Acción desconocida: ${JSON.stringify(imposible)}`);
}
}
}
// Estado derivado: se calcula, no se guarda
export function totalCesta(estado: EstadoCesta): number {
return estado.lineas.reduce((suma, l) => suma + l.producto.precio * l.cantidad, 0);
}
export function unidadesCesta(estado: EstadoCesta): number {
return estado.lineas.reduce((suma, l) => suma + l.cantidad, 0);
}
Merece la pena detenerse en cuatro decisiones:
- Un caso puede reutilizar otro.
producto_anadidodelega encantidad_cambiada, así que el límite de stock existe en un único sitio. Es una llamada recursiva normal, porque el reductor es una función corriente. - La línea guarda el producto entero. Evita tener que buscar el precio en otra lista.
- El total no está en el estado. Es un dato derivado (módulo 04). Por eso
totalCestayunidadesCestason funciones que se calculan al pintar. - Vaciar devuelve
cestaVacia. No hace falta crear otro objeto.
El componente queda reducido a pintar y a informar. Este es src/App.tsx:
import { useReducer } from 'react';
import { cestaReducer, cestaVacia, totalCesta, unidadesCesta } from './cesta/cestaReducer';
import type { Producto } from './cesta/cestaReducer';
const catalogo: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 },
{ productoId: 5, nombre: 'Torre Workstation', precio: 1899, stock: 4 },
{ productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9, stock: 200 },
];
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
export default function App() {
const [cesta, dispatch] = useReducer(cestaReducer, cestaVacia);
return (
<div className="rejilla">
<section className="tarjeta">
<h2>Catálogo</h2>
{catalogo.map((producto) => (
<div key={producto.productoId} className="fila separada">
<span>
{producto.nombre} <span className="tenue">{euros.format(producto.precio)}</span>
</span>
<button onClick={() => dispatch({ type: 'producto_anadido', producto })}>Añadir</button>
</div>
))}
</section>
<section className="tarjeta">
<h2>Cesta ({unidadesCesta(cesta)})</h2>
{cesta.lineas.length === 0 && <p className="tenue">La cesta está vacía</p>}
{cesta.lineas.map(({ producto, cantidad }) => (
<div key={producto.productoId} className="fila separada">
<span>{producto.nombre}</span>
<span className="fila">
<input
type="number"
aria-label={`Cantidad de ${producto.nombre}`}
min={0}
max={producto.stock}
value={cantidad}
style={{ width: '4rem' }}
onChange={(e) =>
dispatch({
type: 'cantidad_cambiada',
productoId: producto.productoId,
cantidad: e.target.valueAsNumber || 0,
})
}
/>
<button
aria-label={`Quitar ${producto.nombre}`}
onClick={() =>
dispatch({ type: 'producto_quitado', productoId: producto.productoId })
}
>
✕
</button>
</span>
</div>
))}
<p>
Total: <strong>{euros.format(totalCesta(cesta))}</strong>
</p>
<button disabled={cesta.lineas.length === 0} onClick={() => dispatch({ type: 'cesta_vaciada' })}>
Vaciar
</button>
</section>
</div>
);
}

Figura 4. La cesta tras añadir productos y escribir 5 en la cantidad de ratones. El total se recalcula en cada render.
Probar el reductor sin montar nada
Como un reductor es una función pura sin React, se prueba como cualquier función: se llama con un estado y una acción, y se comprueba lo que devuelve. Vitest se estudia en el módulo 22, pero el ejemplo se entiende sin más. Crea src/cesta/cestaReducer.test.ts:
import { describe, expect, it } from 'vitest';
import { cestaReducer, cestaVacia, totalCesta, unidadesCesta } from './cestaReducer';
import type { Producto } from './cestaReducer';
const raton: Producto = { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9, stock: 3 };
const portatil: Producto = { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 };
describe('cestaReducer', () => {
it('añade una línea nueva con cantidad 1', () => {
const estado = cestaReducer(cestaVacia, { type: 'producto_anadido', producto: raton });
expect(estado.lineas).toEqual([{ producto: raton, cantidad: 1 }]);
});
it('suma una unidad si el producto ya está en la cesta', () => {
let estado = cestaReducer(cestaVacia, { type: 'producto_anadido', producto: raton });
estado = cestaReducer(estado, { type: 'producto_anadido', producto: raton });
expect(estado.lineas).toHaveLength(1);
expect(estado.lineas[0].cantidad).toBe(2);
});
it('no supera el stock disponible', () => {
let estado = cestaReducer(cestaVacia, { type: 'producto_anadido', producto: raton });
estado = cestaReducer(estado, { type: 'cantidad_cambiada', productoId: 6, cantidad: 50 });
expect(estado.lineas[0].cantidad).toBe(3);
});
it('quita la línea si la cantidad llega a cero', () => {
const inicial = cestaReducer(cestaVacia, { type: 'producto_anadido', producto: raton });
const estado = cestaReducer(inicial, { type: 'cantidad_cambiada', productoId: 6, cantidad: 0 });
expect(estado.lineas).toEqual([]);
});
it('no modifica el estado recibido', () => {
const inicial = Object.freeze({ lineas: Object.freeze([]) as never });
expect(() =>
cestaReducer(inicial, { type: 'producto_anadido', producto: raton }),
).not.toThrow();
});
it('calcula total y unidades', () => {
let estado = cestaReducer(cestaVacia, { type: 'producto_anadido', producto: raton });
estado = cestaReducer(estado, { type: 'producto_anadido', producto: raton });
estado = cestaReducer(estado, { type: 'producto_anadido', producto: portatil });
expect(unidadesCesta(estado)).toBe(3);
expect(totalCesta(estado)).toBeCloseTo(938.8);
});
});
npm install -D vitest
npx vitest run --reporter=verbose
✓ src/cesta/cestaReducer.test.ts > cestaReducer > añade una línea nueva con cantidad 1
✓ src/cesta/cestaReducer.test.ts > cestaReducer > suma una unidad si el producto ya está en la cesta
✓ src/cesta/cestaReducer.test.ts > cestaReducer > no supera el stock disponible
✓ src/cesta/cestaReducer.test.ts > cestaReducer > quita la línea si la cantidad llega a cero
✓ src/cesta/cestaReducer.test.ts > cestaReducer > no modifica el estado recibido
✓ src/cesta/cestaReducer.test.ts > cestaReducer > calcula total y unidades
Test Files 1 passed (1)
Tests 6 passed (6)
💡 Consejo: La prueba “no modifica el estado recibido” pasa un estado congelado con
Object.freeze. Si el reductor intentara hacerpush, se lanzaría una excepción. Es una forma barata de vigilar la pureza.
7. Caso real: cargar datos como máquina de estados
Una petición a una API suele escribirse con tres useState: cargando, error y datos. Pero tres booleanos o nulos admiten ocho combinaciones, y solo tres tienen sentido. Con una unión discriminada, las situaciones imposibles no se pueden escribir: si la fase es error, simplemente no existe datos.
Esto se llama modelar el estado como una máquina de estados: un conjunto cerrado de fases y unas acciones que pasan de una a otra.
import { useEffect, useReducer, useState } from 'react';
type Empleado = {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
departamento: string;
};
type Pagina<T> = { contenido: T[]; total: number };
type EstadoCarga<T> =
| { fase: 'cargando' }
| { fase: 'exito'; datos: T }
| { fase: 'error'; mensaje: string };
type AccionCarga<T> =
| { type: 'peticion_iniciada' }
| { type: 'peticion_resuelta'; datos: T }
| { type: 'peticion_fallida'; mensaje: string };
function cargaReducer<T>(_estado: EstadoCarga<T>, accion: AccionCarga<T>): EstadoCarga<T> {
switch (accion.type) {
case 'peticion_iniciada':
return { fase: 'cargando' };
case 'peticion_resuelta':
return { fase: 'exito', datos: accion.datos };
case 'peticion_fallida':
return { fase: 'error', mensaje: accion.mensaje };
}
}
const API = 'http://localhost:3000/api';
export default function App() {
const [estado, dispatch] = useReducer(cargaReducer<Pagina<Empleado>>, { fase: 'cargando' });
const [peticion, setPeticion] = useState({ ruta: '/empleados?tamano=5&orden=-salario', intento: 0 });
useEffect(() => {
const control = new AbortController();
async function cargar() {
try {
const respuesta = await fetch(API + peticion.ruta, { signal: control.signal });
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`);
const datos: Pagina<Empleado> = await respuesta.json();
dispatch({ type: 'peticion_resuelta', datos });
} catch (error) {
if (control.signal.aborted) return;
dispatch({ type: 'peticion_fallida', mensaje: (error as Error).message });
}
}
cargar();
return () => control.abort();
}, [peticion]);
function pedir(ruta: string) {
dispatch({ type: 'peticion_iniciada' });
setPeticion((p) => ({ ruta, intento: p.intento + 1 }));
}
return (
<section className="tarjeta">
<h2>Empleados mejor pagados</h2>
<div className="fila">
<button onClick={() => pedir('/empleados?tamano=5&orden=-salario')}>Recargar</button>
<button onClick={() => pedir('/empleados/999999')}>Provocar un error</button>
</div>
{estado.fase === 'cargando' && <p className="tenue">Cargando...</p>}
{estado.fase === 'error' && <p className="alerta">{estado.mensaje}</p>}
{estado.fase === 'exito' && (
<table>
<tbody>
{estado.datos.contenido.map((e) => (
<tr key={e.empId}>
<td>
{e.nombre} {e.apellidos}
</td>
<td>{e.oficio}</td>
<td>{e.departamento}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}

Figura 5. Fase exito: la tabla solo existe en esta rama.

Figura 6. Fase error, tras pedir un empleado que no existe. Los datos anteriores desaparecen porque en esta fase no existen.
Fíjate en estos detalles:
cargaReduceres genérico, ycargaReducer<Pagina<Empleado>>fija el tipo con una expresión de instanciación de TypeScript.- Al usar
estado.datosdentro deestado.fase === 'exito', TypeScript sabe quedatosexiste. Fuera de esa condición no compila. - El reductor ignora el estado anterior (
_estado), porque aquí cada acción determina por sí sola la fase siguiente. Si quisieras conservar los datos viejos mientras se recarga, añadirías una faserecargandocondatos. dispatchfunciona igual después de unawait, y es seguro, porque no depende de ninguna instantánea.peticion_iniciadase lanza en el manejador y no en el efecto, para no encadenar un render de más.- El efecto usa
AbortController, que evita las condiciones de carrera (módulo 09). Por eso elcatchignora las peticiones canceladas.
💡 Consejo: Escribir esto a mano es útil para aprender, pero en una aplicación real gestionarás los datos del servidor con TanStack Query (módulo 19), que ya resuelve caché, reintentos e invalidación. La máquina de estados sigue siendo válida para flujos de cliente: asistentes, reproductores o formularios por pasos.
8. useState o useReducer
No son rivales. De hecho, por dentro son casi lo mismo: en el código de react-dom 19.3.1, useState se actualiza llamando a updateReducer(basicStateReducer), un reductor que simplemente sustituye el valor. La diferencia es de organización:
| Criterio | useState | useReducer |
|---|---|---|
| Datos | Independientes y simples | Varios campos que cambian a la vez |
| Lógica de actualización | Una línea en el manejador | Reglas y validaciones |
| Número de eventos que cambian el estado | Pocos | Muchos |
| Dónde se lee la lógica | Repartida por los manejadores | En una sola función |
| Pruebas | A través del componente | Función pura, sin React |
| Depuración | Hay que buscar quién llama al set | Basta un console.log(accion) en el reductor |
| Código | Menos | Más al principio |
| Pasar a hijos profundos | Varias funciones | Un solo dispatch estable |
Una regla práctica: empieza con useState. Cambia a useReducer cuando notes que varios set se llaman juntos, que el valor siguiente depende de otros campos, o que al leer el componente no encuentras dónde se decide algo. Puedes mezclar ambos en el mismo componente, como hace el ejemplo anterior, y pasar de uno a otro es un cambio mecánico.
9. Context: el problema de pasar props en cadena
Hasta ahora, para llevar un dato a un componente lejano había que pasarlo por todos los intermedios. A esto se le llama prop drilling. Pagina y Barra reciben usuario sin usarlo, solo para entregárselo a Avatar. Cada intermedio queda acoplado a un dato que no le importa, y cualquier cambio de nombre o tipo los obliga a cambiar a todos.
Figura 7. Con props el dato viaja por todos los niveles. Con contexto, llega directamente a quien lo pide.
Context permite que un componente ofrezca un valor a todo lo que tenga por debajo, a cualquier profundidad, sin pasarlo por props. Se usa en tres pasos:
// 1. Crear el contexto, fuera de cualquier componente
const TemaContexto = createContext<Tema>('claro');
// 2. Proveer un valor a una rama del árbol
<TemaContexto value={tema}>
<Pagina />
</TemaContexto>
// 3. Leerlo en cualquier descendiente
const tema = useContext(TemaContexto);
📘 Concepto: Un contexto es un canal entre un proveedor y sus descendientes. Un componente que lo lee se llama consumidor, y se vuelve a renderizar cuando cambia el valor del proveedor.
Este ejemplo reúne los casos que debes conocer:
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
type Tema = 'claro' | 'oscuro';
const TemaContexto = createContext<Tema>('claro');
function Boton({ children }: { children: ReactNode }) {
const tema = useContext(TemaContexto);
return <button className={`boton-${tema}`}>{children}</button>;
}
function Panel({ titulo, children }: { titulo: string; children: ReactNode }) {
const tema = useContext(TemaContexto);
return (
<section className={`panel panel-${tema}`}>
<strong>{titulo}</strong> <small>(tema: {tema})</small>
<div>{children}</div>
</section>
);
}
export default function App() {
const [tema, setTema] = useState<Tema>('claro');
return (
<>
<button onClick={() => setTema(tema === 'claro' ? 'oscuro' : 'claro')}>Cambiar tema</button>
<TemaContexto value={tema}>
<Panel titulo="Empleados">
<Boton>Nuevo empleado</Boton>
<TemaContexto value="oscuro">
<Panel titulo="Zona de administración">
<Boton>Borrar empleado</Boton>
</Panel>
</TemaContexto>
</Panel>
</TemaContexto>
<Panel titulo="Fuera de cualquier proveedor">
<Boton>Ayuda</Boton>
</Panel>
</>
);
}

Figura 8. Tras pulsar “Cambiar tema”. El último panel no cambia porque está fuera del proveedor.
Este es el resultado real, resumido, antes y después de pulsar el botón:
Inicio: Empleados: claro / Zona de administración: oscuro / Fuera de cualquier proveedor: claro
Tras cambiar: Empleados: oscuro / Zona de administración: oscuro / Fuera de cualquier proveedor: claro
Cuatro reglas explican todo lo que ves:
- Se lee el proveedor más cercano por encima. “Zona de administración” tiene su propio proveedor, y este oculta al exterior. Para eso sirve anidarlos: es una forma natural de personalizar una parte del árbol.
- Los consumidores se actualizan solos. Al pulsar el botón,
PanelyBotoncambian sin recibir ninguna prop. React los busca y los vuelve a renderizar. - El valor por defecto solo se usa sin proveedor. “Fuera de cualquier proveedor” usa
'claro', el argumento decreateContext, y nunca cambia. - El contexto sigue el árbol de React, no el del DOM. Un componente dentro de un portal (módulo 10) lee el proveedor de su padre de React, aunque su nodo esté en otro lugar del documento. Lo hemos comprobado renderizando un portal en un
divexterno.
Figura 9. Cada consumidor busca hacia arriba y se queda con el primer proveedor que encuentra.
Dos sorpresas habituales
Un componente no lee el proveedor que él mismo renderiza. El proveedor afecta a lo que tiene debajo:
function Pagina() {
const tema = useContext(TemaContexto); // 'claro': el valor por defecto, no 'oscuro'
return <TemaContexto value="oscuro"><Contenido /></TemaContexto>; // Contenido sí lee 'oscuro'
}
value={undefined} no activa el valor por defecto. El valor por defecto se usa cuando no hay proveedor, no cuando el proveedor tiene un valor vacío. Lo hemos comprobado en 18.3.1, 19.0.9 y 19.3.1: el consumidor recibe undefined.
⚠️ Importante: Si un contexto parece devolver siempre el valor por defecto, casi seguro que el consumidor está fuera del proveedor, o que tienes dos objetos de contexto distintos. Esto pasa si llamas a
createContextdentro de un componente, porque cada render crea uno nuevo. Créalo siempre en el nivel superior de un módulo.
10. El proveedor: <Context> 🆕 19.0
🆕 Desde React 19.0. El propio contexto actúa como proveedor:
<Contexto value={x}>. No existe en React 18.3 ni anteriores. Si usas una versión anterior: escribe<Contexto.Provider value={x}>. En 18.3.1,<Contexto>se trata como un consumidor y la aplicación se rompe conrender is not a function.
Hasta React 18 el proveedor era una propiedad del contexto, Contexto.Provider. Desde React 19 el propio contexto actúa como proveedor:
// Forma actual
<TemaContexto value={tema}>
<Pagina />
</TemaContexto>
// Forma anterior: sigue funcionando en 19.3.1
<TemaContexto.Provider value={tema}>
<Pagina />
</TemaContexto.Provider>
Puedes migrar sin prisa y mezclar ambas formas. La documentación de createContext llama a Provider “legacy” y Consumer “not recommended”, pero ninguna de las dos se ha eliminado: las tres formas funcionan en 19.3.1.
Si ejecutas <Contexto value="x"> en 18.3.1, estos son los mensajes reales que verás en consola antes del fallo:
Warning: Rendering <Context> directly is not supported and will be removed in a future major release. Did you mean to render <Context.Consumer> instead?
Warning: A context consumer was rendered with multiple children, or a child that isn't a function. ...
TypeError: render is not a function
<Context.Consumer>, la forma antigua de leer
Antes de los hooks se leía el contexto con un componente y una función como hijo. Lo encontrarás en código antiguo, pero no lo uses en código nuevo:
<TemaContexto.Consumer>
{(tema) => <button className={`boton-${tema}`}>Guardar</button>}
</TemaContexto.Consumer>
<Context> en Server Components 🆕 19.3
🆕 Desde React 19.3. Un Server Component puede renderizar
<Context>directamente. No existe en React 19.2 ni anteriores. Si usas una versión anterior: coloca el proveedor en un componente propio marcado con'use client'y úsalo desde el Server Component.
Esto solo afecta a los Server Components, que no se usan en una aplicación Vite como la de este módulo. Se trata en el módulo 23.
11. Leer el contexto: use(Contexto) 🆕 19.0
🆕 Desde React 19.0.
use(Contexto)lee un contexto y admite condiciones y bucles. No existe en React 18.3 ni anteriores: el paquete no exportause. Si usas una versión anterior: lee conuseContext(Contexto)al principio del componente, antes de cualquierreturncondicional. Se pierde la posibilidad de leer el contexto dentro de condiciones o bucles.
use es una función de React que lee un recurso: un contexto o una promesa. Las promesas se tratan en el módulo 15. Con un contexto devuelve lo mismo que useContext, pero tiene una diferencia importante: se puede llamar dentro de condiciones y bucles, y también después de un return anticipado.
import { createContext, use, useState } from 'react';
type Usuario = { nombre: string; rol: 'ADMIN' | 'USER' };
const UsuarioContexto = createContext<Usuario | null>(null);
const empleados = [
{ empId: 100, nombre: 'Ana García', salario: 6500 },
{ empId: 101, nombre: 'Luis Martínez', salario: 4200 },
];
function Salario({ cantidad, mostrar }: { cantidad: number; mostrar: boolean }) {
// Sin datos que enseñar, salimos antes de tocar el contexto
if (!mostrar) return <span className="tenue">oculto</span>;
// use() va después de un return: useContext aquí incumpliría las reglas de los hooks
const usuario = use(UsuarioContexto);
if (usuario?.rol !== 'ADMIN') return <span className="tenue">sin permiso</span>;
return <strong>{cantidad} €</strong>;
}
export default function App() {
const [mostrar, setMostrar] = useState(true);
const [rol, setRol] = useState<Usuario['rol']>('ADMIN');
return (
<UsuarioContexto value={{ nombre: 'Ana García', rol }}>
<section className="tarjeta">
<h2>Nóminas</h2>
<div className="fila">
<button onClick={() => setMostrar(!mostrar)}>Mostrar u ocultar</button>
<button onClick={() => setRol(rol === 'ADMIN' ? 'USER' : 'ADMIN')}>Rol: {rol}</button>
</div>
<table>
<tbody>
{empleados.map((e) => (
<tr key={e.empId}>
<td>{e.nombre}</td>
<td>
<Salario cantidad={e.salario} mostrar={mostrar} />
</td>
</tr>
))}
</tbody>
</table>
</section>
</UsuarioContexto>
);
}
Resultado real, resumido, al pulsar los botones:
ADMIN, visible: Ana García 6500 € / Luis Martínez 4200 €
USER, visible: Ana García sin permiso / Luis Martínez sin permiso
USER, oculto: Ana García oculto / Luis Martínez oculto
La misma lectura con useContext después del return es un error de npm run lint, porque incumple las reglas de los hooks (módulo 04). Esto es lo que da oxlint con la configuración del laboratorio, mientras que la versión con use pasa limpia:
error react-hooks(rules-of-hooks): React Hook "useContext" is called conditionally.
Hemos probado use(Contexto) en un return anticipado, en un bucle y en un if cuya condición cambia entre renders, seguido de otro hook. En 19.0.9 y 19.3.1 funciona en los tres casos y no da ningún aviso.
useContext(Contexto) | use(Contexto) | |
|---|---|---|
| Disponible | Desde React 16.8 | Desde React 19.0 |
Dentro de if y bucles | No | Sí |
Tras un return anticipado | No | Sí |
| Solo durante el render | Sí | Sí |
| Lee promesas | No | Sí |
| Valor devuelto | Proveedor más cercano | Proveedor más cercano |
use no es un permiso para llamarlo en cualquier sitio. Sigue necesitando que React esté renderizando. Esto es lo que ocurre si lo llamas en un manejador de evento, en 19.0.9 y en 19.3.1:
Context can only be read while React is rendering.
La documentación de use añade dos restricciones más: debe llamarse dentro de un componente o de un hook, y no puede ir dentro de un bloque try-catch. Además, leer un contexto con use no está soportado en Server Components (módulo 23).
💡 Consejo: ¿Cuál usar?
useContextsigue siendo correcto y no está obsoleto. Si tu proyecto solo usa React 19,usecubre además las lecturas condicionales. Lo que importa es elegir un criterio para no confundir al que lea el código. En este curso usamosuseContexten los ejemplos básicos, yusedentro de hooks propios o tras una condición.
12. Un contexto seguro
Muchos contextos no tienen un valor por defecto razonable. ¿Qué sesión hay si no hay proveedor? El patrón estándar tiene tres partes:
- Crear el contexto con
null, para que “no hay proveedor” sea detectable. - Escribir un componente proveedor que guarde el estado y la lógica.
- Escribir un hook propio que lea el contexto y lance un error claro si es
null.
Así los consumidores no tienen que comprobar null en todas partes, y olvidar el proveedor se detecta enseguida con un mensaje que dice qué falta. El módulo 12 trata a fondo los hooks personalizados.
Este ejemplo gestiona el inicio de sesión contra la API EMPRESA. Usuario de prueba: ana.garcia@empresa.es, contraseña secreto. Crea src/sesion/SesionContexto.ts:
import { createContext } from 'react';
export type Usuario = { email: string; nombre: string; rol: 'ADMIN' | 'USER' };
export type EstadoSesion =
| { fase: 'anonima'; error: string | null }
| { fase: 'entrando' }
| { fase: 'activa'; token: string; usuario: Usuario };
export type ValorSesion = {
sesion: EstadoSesion;
entrar: (email: string, password: string) => Promise<void>;
salir: () => void;
};
export const SesionContexto = createContext<ValorSesion | null>(null);
SesionContexto.displayName = 'Sesion';
src/sesion/useSesion.ts:
import { use } from 'react';
import { SesionContexto } from './SesionContexto';
export function useSesion() {
const valor = use(SesionContexto);
if (valor === null) {
throw new Error('useSesion debe usarse dentro de <SesionProvider>');
}
return valor;
}
src/sesion/SesionProvider.tsx:
import { useCallback, useMemo, useReducer } from 'react';
import type { ReactNode } from 'react';
import { SesionContexto } from './SesionContexto';
import type { EstadoSesion, Usuario } from './SesionContexto';
type AccionSesion =
| { type: 'entrada_iniciada' }
| { type: 'entrada_correcta'; token: string; usuario: Usuario }
| { type: 'entrada_fallida'; error: string }
| { type: 'sesion_cerrada' };
function sesionReducer(_estado: EstadoSesion, accion: AccionSesion): EstadoSesion {
switch (accion.type) {
case 'entrada_iniciada':
return { fase: 'entrando' };
case 'entrada_correcta':
return { fase: 'activa', token: accion.token, usuario: accion.usuario };
case 'entrada_fallida':
return { fase: 'anonima', error: accion.error };
case 'sesion_cerrada':
return { fase: 'anonima', error: null };
}
}
const API = 'http://localhost:3000/api';
export function SesionProvider({ children }: { children: ReactNode }) {
const [sesion, dispatch] = useReducer(sesionReducer, { fase: 'anonima', error: null });
const entrar = useCallback(async (email: string, password: string) => {
dispatch({ type: 'entrada_iniciada' });
try {
const respuesta = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const cuerpo = await respuesta.json();
if (!respuesta.ok) {
dispatch({ type: 'entrada_fallida', error: cuerpo.mensaje });
return;
}
dispatch({ type: 'entrada_correcta', token: cuerpo.token, usuario: cuerpo.usuario });
} catch {
dispatch({ type: 'entrada_fallida', error: 'No se ha podido contactar con la API' });
}
}, []);
const salir = useCallback(() => dispatch({ type: 'sesion_cerrada' }), []);
const valor = useMemo(() => ({ sesion, entrar, salir }), [sesion, entrar, salir]);
return <SesionContexto value={valor}>{children}</SesionContexto>;
}
src/Login.tsx:
import { useState } from 'react';
import type { SubmitEvent } from 'react';
import { useSesion } from './sesion/useSesion';
export function Login() {
const { sesion, entrar } = useSesion();
const [email, setEmail] = useState('ana.garcia@empresa.es');
const [password, setPassword] = useState('');
function enviar(evento: SubmitEvent<HTMLFormElement>) {
evento.preventDefault();
entrar(email, password);
}
return (
<form className="tarjeta" onSubmit={enviar}>
<h2>Iniciar sesión</h2>
<p>
<label>
Email <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</label>
</p>
<p>
<label>
Contraseña{' '}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</label>
</p>
{sesion.fase === 'anonima' && sesion.error && <p className="alerta">{sesion.error}</p>}
<button disabled={sesion.fase === 'entrando'}>
{sesion.fase === 'entrando' ? 'Entrando...' : 'Entrar'}
</button>
</form>
);
}
src/Cabecera.tsx:
import { useSesion } from './sesion/useSesion';
export function Cabecera() {
const { sesion, salir } = useSesion();
if (sesion.fase !== 'activa') return null;
return (
<header className="tarjeta fila separada">
<span>
Hola, <strong>{sesion.usuario.nombre}</strong> ({sesion.usuario.rol})
</span>
<button onClick={salir}>Cerrar sesión</button>
</header>
);
}
Y src/App.tsx:
import { Cabecera } from './Cabecera';
import { Login } from './Login';
import { SesionProvider } from './sesion/SesionProvider';
import { useSesion } from './sesion/useSesion';
function Contenido() {
const { sesion } = useSesion();
if (sesion.fase !== 'activa') return <Login />;
return <p className="tarjeta">Ya puedes usar la aplicación EMPRESA.</p>;
}
export default function App() {
return (
<SesionProvider>
<Cabecera />
<Contenido />
</SesionProvider>
);
}

Figura 10. Con una contraseña errónea, la API responde 401 y su mensaje llega al estado a través de entrada_fallida.

Figura 11. Con la contraseña correcta. Cabecera y Login no se pasan nada: comparten el mismo contexto.
⚠️ Importante: El evento de envío se tipa con
SubmitEvent<HTMLFormElement>, importado dereact. En@types/react19.3.0,FormEventestá marcado como obsoleto. No olvides elimport type: sin él, TypeScript usa elSubmitEventglobal del DOM, que es otro tipo y no admite ese parámetro genérico. En@types/react18.3.31 no existeSubmitEvent: allí usaFormEvent<HTMLFormElement>.
Hay mucho que comentar aquí:
dispatchtras unawait. Es correcto. Una funciónasyncno puede ser un reductor, porque un reductor es puro y síncrono. Por esoentrarvive en el proveedor: hace la parte asíncrona y va despachando hechos.- La sesión vive solo en memoria. Si recargas la página, desaparece. El proyecto final completa el inicio de sesión con JWT.
useCallbackyuseMemo. Sirven para quevalorno sea un objeto nuevo en cada render. La sección 14 mide por qué importa.displayName. Es el nombre con el que aparece el contexto en React DevTools. Sin él, solo verías un nombre genérico.
Si un componente usa useSesion fuera del proveedor, obtienes este error real, que una prueba del laboratorio confirma:
Error: useSesion debe usarse dentro de <SesionProvider>
Un fichero para cada cosa
¿Por qué tres ficheros? Porque Fast Refresh (módulo 01) solo conserva el estado si un fichero exporta únicamente componentes. Si el proveedor, el contexto y el hook van juntos en Sesion.tsx, oxlint avisa, con la regla react/only-export-components, activada en la configuración del laboratorio:
warning react(only-export-components): Fast refresh only works when a file only exports components.
13. Reductor y contexto juntos
Por separado, un reductor organiza las actualizaciones y un contexto reparte datos. Juntos forman una forma ligera de gestionar el estado compartido de una rama de la aplicación, sin librerías externas:
Figura 12. Un proveedor, dos contextos y componentes que piden solo lo que necesitan.
La receta usa los dos contextos de la figura: uno para el estado y otro para dispatch. Reutiliza el cestaReducer.ts de la sección 6. Después, crea src/cesta/contextos.ts:
import { createContext } from 'react';
import type { Dispatch } from 'react';
import type { AccionCesta, EstadoCesta } from './cestaReducer';
export const CestaContexto = createContext<EstadoCesta | null>(null);
CestaContexto.displayName = 'Cesta';
export const CestaDispatchContexto = createContext<Dispatch<AccionCesta> | null>(null);
CestaDispatchContexto.displayName = 'CestaDispatch';
src/cesta/useCesta.ts:
import { use } from 'react';
import { CestaContexto, CestaDispatchContexto } from './contextos';
export function useCesta() {
const cesta = use(CestaContexto);
if (cesta === null) throw new Error('useCesta debe usarse dentro de <CestaProvider>');
return cesta;
}
export function useCestaDispatch() {
const dispatch = use(CestaDispatchContexto);
if (dispatch === null) throw new Error('useCestaDispatch debe usarse dentro de <CestaProvider>');
return dispatch;
}
src/cesta/CestaProvider.tsx:
import { useReducer } from 'react';
import type { ReactNode } from 'react';
import { cestaReducer, cestaVacia } from './cestaReducer';
import { CestaContexto, CestaDispatchContexto } from './contextos';
export function CestaProvider({ children }: { children: ReactNode }) {
const [cesta, dispatch] = useReducer(cestaReducer, cestaVacia);
return (
<CestaContexto value={cesta}>
<CestaDispatchContexto value={dispatch}>{children}</CestaDispatchContexto>
</CestaContexto>
);
}
Los componentes de la tienda están separados entre sí y no se pasan ninguna prop relacionada con la cesta. src/Catalogo.tsx:
import { useEffect, useState } from 'react';
import { useCesta, useCestaDispatch } from './cesta/useCesta';
import type { Producto } from './cesta/cestaReducer';
const API = 'http://localhost:3000/api';
function BotonAnadir({ producto }: { producto: Producto }) {
const dispatch = useCestaDispatch();
return <button onClick={() => dispatch({ type: 'producto_anadido', producto })}>Añadir</button>;
}
export function Catalogo() {
const [productos, setProductos] = useState<Producto[]>([]);
useEffect(() => {
const control = new AbortController();
fetch(`${API}/productos?disponibles=true&tamano=5&orden=-precio`, { signal: control.signal })
.then((r) => r.json())
.then((pagina) => setProductos(pagina.contenido))
.catch(() => {});
return () => control.abort();
}, []);
return (
<section className="tarjeta">
<h2>Catálogo</h2>
{productos.map((producto) => (
<div key={producto.productoId} className="fila separada">
<span>{producto.nombre}</span>
<span className="fila">
<span className="tenue">{producto.precio} €</span>
<BotonAnadir producto={producto} />
</span>
</div>
))}
</section>
);
}
// Lee la cesta para marcar los productos que ya contiene
export function AvisoRepetidos() {
const cesta = useCesta();
const repetidas = cesta.lineas.filter((l) => l.cantidad > 1).length;
if (repetidas === 0) return null;
return <p className="tenue">Productos con varias unidades: {repetidas}</p>;
}
src/Resumen.tsx:
import { totalCesta, unidadesCesta } from './cesta/cestaReducer';
import { useCesta, useCestaDispatch } from './cesta/useCesta';
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
export function Insignia() {
const cesta = useCesta();
return <span className="insignia">{unidadesCesta(cesta)}</span>;
}
export function Resumen() {
const cesta = useCesta();
const dispatch = useCestaDispatch();
return (
<aside className="tarjeta">
<h2>Tu cesta</h2>
{cesta.lineas.length === 0 && <p className="tenue">Aún no has añadido nada</p>}
{cesta.lineas.map(({ producto, cantidad }) => (
<div key={producto.productoId} className="fila separada">
<span>
{cantidad} × {producto.nombre}
</span>
<button
aria-label={`Quitar ${producto.nombre}`}
onClick={() => dispatch({ type: 'producto_quitado', productoId: producto.productoId })}
>
✕
</button>
</div>
))}
<p>
Total: <strong>{euros.format(totalCesta(cesta))}</strong>
</p>
<button onClick={() => dispatch({ type: 'cesta_vaciada' })}>Vaciar</button>
</aside>
);
}
src/App.tsx:
import { AvisoRepetidos, Catalogo } from './Catalogo';
import { CestaProvider } from './cesta/CestaProvider';
import { Insignia, Resumen } from './Resumen';
export default function App() {
return (
<CestaProvider>
<header className="tarjeta fila separada" style={{ maxWidth: 'none' }}>
<strong>Tienda EMPRESA</strong>
<span>
Cesta <Insignia />
</span>
</header>
<div className="rejilla">
<div>
<Catalogo />
<AvisoRepetidos />
</div>
<Resumen />
</div>
</CestaProvider>
);
}

Figura 13. La insignia de la cabecera, el catálogo, el aviso y el resumen comparten el estado sin pasárselo por props.
Compáralo con la sección 6: el reductor es el mismo fichero, sin cambiar ni una línea. Esa es la ventaja de separar la lógica de dónde se usa. Y App no sabe nada de la cesta, solo coloca a sus hijos.
💡 Consejo: ¿Dónde pongo el proveedor? Lo más abajo posible, justo por encima de todos los que lo necesitan. Si solo la tienda usa la cesta, no lo pongas en la raíz de la aplicación: además de ahorrar renders, su estado se reinicia al salir de esa rama.
14. Rendimiento del contexto
Un contexto tiene una regla muy simple, y de ella sale todo lo demás:
📘 Concepto: Cuando cambia el
valuede un proveedor, comparado conObject.is, todos sus consumidores se vuelven a renderizar.memono los protege, y no se puede leer solo una parte del valor.
Vamos a medirlo. Este ejemplo tiene cuatro escenarios, y cada marcador cuenta cuántas veces se ha confirmado un render de ese componente. Todos arrancan en 2 porque Strict Mode ejecuta dos veces los efectos al montar. Los incrementos son los que importan:
import { createContext, memo, use, useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react';
// Pinta cuántas veces se ha confirmado un render de este componente
function useRenders() {
const veces = useRef(0);
const marca = useRef<HTMLElement>(null);
useEffect(() => {
veces.current += 1;
if (marca.current) marca.current.textContent = String(veces.current);
});
return marca;
}
function Marcador({ nombre, marca }: { nombre: string; marca: RefObject<HTMLElement | null> }) {
return (
<p className="fila separada">
<span>{nombre}</span>
<strong ref={marca}>0</strong>
</p>
);
}
type Valor = { cuenta: number; setCuenta: Dispatch<SetStateAction<number>> };
// Escenario A: objeto nuevo en cada render
const ContextoA = createContext<Valor | null>(null);
const SoloEscribeA = memo(function SoloEscribeA() {
const marca = useRenders();
use(ContextoA);
return <Marcador nombre="Consumidor con memo" marca={marca} />;
});
function EscenarioA() {
const [cuenta, setCuenta] = useState(0);
const [, setAjeno] = useState(0);
return (
<ContextoA value={{ cuenta, setCuenta }}>
<section className="tarjeta">
<h2>A. Objeto nuevo</h2>
<button onClick={() => setAjeno((n) => n + 1)}>Estado ajeno</button>
<SoloEscribeA />
</section>
</ContextoA>
);
}
// Escenario B: el mismo valor, memorizado
const ContextoB = createContext<Valor | null>(null);
const SoloEscribeB = memo(function SoloEscribeB() {
const marca = useRenders();
use(ContextoB);
return <Marcador nombre="Consumidor con memo" marca={marca} />;
});
function EscenarioB() {
const [cuenta, setCuenta] = useState(0);
const [, setAjeno] = useState(0);
const valor = useMemo(() => ({ cuenta, setCuenta }), [cuenta]);
return (
<ContextoB value={valor}>
<section className="tarjeta">
<h2>B. Con useMemo</h2>
<button onClick={() => setAjeno((n) => n + 1)}>Estado ajeno</button>
<SoloEscribeB />
</section>
</ContextoB>
);
}
// Escenario C: un contexto para todo; el que solo escribe paga por leer
const ContextoC = createContext<Valor | null>(null);
const BotonC = memo(function BotonC() {
const marca = useRenders();
const valor = use(ContextoC);
return (
<>
<button onClick={() => valor?.setCuenta((n) => n + 1)}>Sumar</button>
<Marcador nombre="Solo escribe" marca={marca} />
</>
);
});
const LecturaC = memo(function LecturaC() {
const marca = useRenders();
const valor = use(ContextoC);
return <Marcador nombre={`Lee: ${valor?.cuenta}`} marca={marca} />;
});
function EscenarioC() {
const [cuenta, setCuenta] = useState(0);
const valor = useMemo(() => ({ cuenta, setCuenta }), [cuenta]);
return (
<ContextoC value={valor}>
<section className="tarjeta">
<h2>C. Un contexto</h2>
<BotonC />
<LecturaC />
</section>
</ContextoC>
);
}
// Escenario D: dos contextos, lectura y escritura separadas
const CuentaD = createContext(0);
const SetCuentaD = createContext<Dispatch<SetStateAction<number>> | null>(null);
function ProveedorD({ children }: { children: ReactNode }) {
const [cuenta, setCuenta] = useState(0);
return (
<CuentaD value={cuenta}>
<SetCuentaD value={setCuenta}>{children}</SetCuentaD>
</CuentaD>
);
}
function BotonD() {
const marca = useRenders();
const setCuenta = use(SetCuentaD);
return (
<>
<button onClick={() => setCuenta?.((n) => n + 1)}>Sumar</button>
<Marcador nombre="Solo escribe" marca={marca} />
</>
);
}
function LecturaD() {
const marca = useRenders();
const cuenta = use(CuentaD);
return <Marcador nombre={`Lee: ${cuenta}`} marca={marca} />;
}
function Ajeno() {
const marca = useRenders();
return <Marcador nombre="No usa el contexto" marca={marca} />;
}
function EscenarioD() {
return (
<ProveedorD>
<section className="tarjeta">
<h2>D. Dos contextos</h2>
<BotonD />
<LecturaD />
<Ajeno />
</section>
</ProveedorD>
);
}
export default function App() {
return (
<div className="rejilla">
<EscenarioA />
<EscenarioB />
<EscenarioC />
<EscenarioD />
</div>
);
}
Hemos pulsado cinco veces el botón de cada escenario:

Figura 14. Medición real tras cinco clics en cada escenario.
| Escenario | Componente | Inicio | Tras 5 clics | Qué demuestra |
|---|---|---|---|---|
| A. Objeto nuevo | Consumidor con memo | 2 | 7 | value={{ ... }} crea un objeto en cada render: memo no sirve de nada |
B. Con useMemo | Consumidor con memo | 2 | 2 | Mismo objeto si la cuenta no cambia: el estado ajeno ya no le afecta |
| C. Un contexto | Solo escribe | 2 | 7 | Aunque no lea la cuenta, paga por compartir contexto con ella |
| C. Un contexto | Lee la cuenta | 2 | 7 | Correcto: el dato ha cambiado |
| D. Dos contextos | Solo escribe | 2 | 2 | Su contexto nunca cambia, así que no se renderiza |
| D. Dos contextos | Lee la cuenta | 2 | 7 | Correcto: el dato ha cambiado |
| D. Dos contextos | No usa el contexto | 2 | 2 | children no se vuelve a crear porque lo crea quien usa ProveedorD |
Figura 15. Separar la lectura de la escritura.
De aquí salen tres técnicas, por orden de utilidad:
1. Memoriza el valor si es un objeto. Es el error más común. Cualquier render del proveedor crea un objeto nuevo, aunque los datos sean iguales. Si el valor es un primitivo o dispatch, no hace falta nada.
// ❌ Objeto nuevo en cada render
<SesionContexto value={{ sesion, entrar, salir }}>
// ✅ Mismo objeto mientras no cambien sus partes
const valor = useMemo(() => ({ sesion, entrar, salir }), [sesion, entrar, salir]);
<SesionContexto value={valor}>
2. Separa lo que cambia a distinto ritmo. Lectura y escritura en contextos distintos, como hizo la sección 13, o bien temas y sesión en contextos independientes. Un contexto “global” con todo dentro es el peor caso: cualquier cambio renderiza todos los consumidores.
3. Saca el estado a un proveedor que reciba children. Fíjate en ProveedorD: cuando su estado cambia se vuelve a ejecutar, pero la prop children es el mismo objeto de antes, porque se creó fuera, y React no renderiza de nuevo lo que no consume el contexto. Por eso “No usa el contexto” sigue en 2. Si el estado estuviera en el mismo componente que escribe esos JSX, sí se renderizarían.
⚠️ Importante: No hay ningún selector:
use(Contexto)no permite suscribirse acontexto.lineas.length. Si un valor cambia muchas veces por segundo, como la posición del ratón, o necesitas suscripciones finas, Context es la herramienta equivocada. Las librerías del módulo 20 resuelven esto con selectores.
💡 Consejo: No optimices antes de medir. Un render que tarda un milisegundo no merece un contexto más. Lo importante es no crear objetos nuevos sin motivo. El módulo 14 enseña a medir con el Profiler, y explica qué parte de estas memorizaciones hace por ti React Compiler.
15. Cuándo no usar Context
Context es cómodo, y por eso se abusa de él. Cuando tengas un problema de props, recorre esta lista en orden:
| Situación | Mejor opción |
|---|---|
| El dato baja dos o tres niveles | Props. Explícitas, fáciles de seguir y sin magia |
| Los intermedios solo reenvían JSX | Composición con children o props de JSX |
| Dato que cambia poco y usa medio árbol: tema, idioma, sesión | Context |
| Estado complejo de una rama: cesta, asistente, editor | Reductor y contexto |
| Datos del servidor: listas, detalles, caché | TanStack Query (módulo 19) |
| Estado que debe compartirse por URL: filtros, página | La URL con React Router (módulo 18) |
| Estado global con muchos cambios o muchos consumidores | Zustand o Redux Toolkit (módulo 20) |
| Valores de un formulario | El propio formulario (módulo 21) |
Antes de un contexto, prueba la composición
Muchas veces el prop drilling desaparece si los intermedios reciben el JSX ya construido, en lugar de los datos para construirlo:
import type { ReactNode } from 'react';
type Usuario = { nombre: string; rol: string };
function Avatar({ usuario }: { usuario: Usuario }) {
return (
<strong>
{usuario.nombre} ({usuario.rol})
</strong>
);
}
function Barra({ children }: { children: ReactNode }) {
return <div className="fila separada">{children}</div>;
}
function Cabecera({ derecha }: { derecha: ReactNode }) {
return (
<header className="tarjeta">
<Barra>
<span>EMPRESA</span>
{derecha}
</Barra>
</header>
);
}
export default function App() {
const usuario: Usuario = { nombre: 'Ana García', rol: 'ADMIN' };
// Cabecera y Barra no saben nada del usuario: solo colocan lo que reciben
return <Cabecera derecha={<Avatar usuario={usuario} />} />;
}
Cabecera y Barra no reciben usuario, así que no se acoplan a él. App construye Avatar donde sí tiene el dato. Además, si usuario cambia, es más barato: no hay consumidores que buscar.
El coste de usar Context es real:
- Dependencia oculta. Las props de un componente ya no cuentan todo lo que necesita.
- Peor reutilización. El componente solo funciona dentro de ese proveedor.
- Pruebas más largas. Hay que envolverlo en el proveedor.
- Más renders, si el valor cambia a menudo.
16. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
| El reductor añade el doble | Muta el estado, y Strict Mode lo llama dos veces | Devuelve datos nuevos |
Cannot read properties of undefined tras un dispatch | Falta un return o un caso del switch | Tipo de retorno explícito y default con never |
Too many re-renders | dispatch llamado durante el render | Llámalo en un manejador o en un efecto |
El estado no cambia justo después de dispatch | El estado es una instantánea | Usa el valor en el siguiente render o calcúlalo antes |
| El valor es siempre el de por defecto | Consumidor fuera del proveedor, o está en el mismo componente que lo provee | Sube el proveedor o extrae un hijo |
| El contexto cambia en cada render | createContext dentro de un componente | Sácalo al nivel superior del módulo |
Consumidores con memo se renderizan sin motivo | value es un objeto nuevo | useMemo, o separa contextos |
render is not a function | <Contexto value> en React 18 | Usa <Contexto.Provider> o actualiza |
Context can only be read while React is rendering | use en un manejador | Léelo en el render y usa el valor |
| Un componente pierde su estado al guardar el fichero | Fichero que exporta componentes y hooks | Separa ficheros |
Depurar
Para saber qué ocurre en un reductor, envuélvelo de forma temporal en otro que registre cada acción. Como todas pasan por el mismo sitio, tienes un historial completo:
function conRegistro<S, A>(reductor: (estado: S, accion: A) => S) {
return (estado: S, accion: A): S => {
const siguiente = reductor(estado, accion);
console.log(accion, estado, siguiente);
return siguiente;
};
}
const registrado = conRegistro(cestaReducer); // fuera del componente
const [cesta, dispatch] = useReducer(registrado, cestaVacia);
En React DevTools, la pestaña Components muestra el valor de cada proveedor al seleccionarlo, y los hooks de cada consumidor. Es una buena forma de comprobar si un consumidor está de verdad dentro del proveedor que crees.
17. Ejercicios
Ejercicio 1: Ciclo de vida de un pedido
Los pedidos de la API EMPRESA tienen los estados PENDIENTE, ENVIADO, ENTREGADO y CANCELADO. Escribe un pedidoReducer con las acciones enviar (con fecha), entregar y cancelar. Solo se puede enviar o cancelar un pedido pendiente y solo se puede entregar uno enviado. Una transición no válida debe devolver el mismo objeto. Añade pruebas.
Ver solución
export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';
export type Pedido = { pedidoId: number; estado: EstadoPedido; fechaEnvio: string | null };
export type AccionPedido =
| { type: 'enviar'; fecha: string }
| { type: 'entregar' }
| { type: 'cancelar' };
export function pedidoReducer(pedido: Pedido, accion: AccionPedido): Pedido {
switch (accion.type) {
case 'enviar':
if (pedido.estado !== 'PENDIENTE') return pedido;
return { ...pedido, estado: 'ENVIADO', fechaEnvio: accion.fecha };
case 'entregar':
if (pedido.estado !== 'ENVIADO') return pedido;
return { ...pedido, estado: 'ENTREGADO' };
case 'cancelar':
if (pedido.estado !== 'PENDIENTE') return pedido;
return { ...pedido, estado: 'CANCELADO' };
default: {
const imposible: never = accion;
throw new Error(`Acción desconocida: ${JSON.stringify(imposible)}`);
}
}
}
import { expect, it } from 'vitest';
import { pedidoReducer } from './pedidoReducer';
import type { Pedido } from './pedidoReducer';
const pendiente: Pedido = { pedidoId: 5001, estado: 'PENDIENTE', fechaEnvio: null };
it('recorre el camino normal', () => {
const enviado = pedidoReducer(pendiente, { type: 'enviar', fecha: '2026-11-02' });
expect(enviado).toEqual({ pedidoId: 5001, estado: 'ENVIADO', fechaEnvio: '2026-11-02' });
expect(pedidoReducer(enviado, { type: 'entregar' }).estado).toBe('ENTREGADO');
});
it('devuelve el MISMO objeto si la transición no es válida', () => {
expect(pedidoReducer(pendiente, { type: 'entregar' })).toBe(pendiente);
const cancelado = pedidoReducer(pendiente, { type: 'cancelar' });
expect(pedidoReducer(cancelado, { type: 'enviar', fecha: '2026-11-02' })).toBe(cancelado);
});
La fecha llega en la acción porque leerla con new Date() en el reductor lo haría impuro. Devolver pedido tal cual en las transiciones inválidas activa el bail out, y toBe lo comprueba: compara identidad, no contenido.
Ejercicio 2: Filtros de empleados
Crea una lista de empleados paginada contra la API (/api/empleados, 5 por página) con buscador, selector de orden, paginación y un botón para limpiar los filtros. Usa un reductor para los filtros. Al cambiar la búsqueda o el orden, se debe volver a la página 1.
Ver solución
import { useEffect, useReducer, useState } from 'react';
type Empleado = { empId: number; nombre: string; apellidos: string; salario: number };
type Pagina = { contenido: Empleado[]; pagina: number; paginas: number; total: number };
type Filtros = { q: string; orden: string; pagina: number };
type AccionFiltros =
| { type: 'busqueda_cambiada'; q: string }
| { type: 'orden_cambiado'; orden: string }
| { type: 'pagina_cambiada'; pagina: number }
| { type: 'filtros_limpiados' };
const filtrosIniciales: Filtros = { q: '', orden: 'apellidos', pagina: 1 };
function filtrosReducer(filtros: Filtros, accion: AccionFiltros): Filtros {
switch (accion.type) {
case 'busqueda_cambiada':
return { ...filtros, q: accion.q, pagina: 1 };
case 'orden_cambiado':
return { ...filtros, orden: accion.orden, pagina: 1 };
case 'pagina_cambiada':
return { ...filtros, pagina: accion.pagina };
case 'filtros_limpiados':
return filtrosIniciales;
}
}
const API = 'http://localhost:3000/api';
export default function App() {
const [filtros, dispatch] = useReducer(filtrosReducer, filtrosIniciales);
const [pagina, setPagina] = useState<Pagina | null>(null);
useEffect(() => {
const control = new AbortController();
const parametros = new URLSearchParams({
q: filtros.q,
orden: filtros.orden,
pagina: String(filtros.pagina),
tamano: '5',
});
fetch(`${API}/empleados?${parametros}`, { signal: control.signal })
.then((r) => r.json())
.then(setPagina)
.catch(() => {});
return () => control.abort();
}, [filtros]);
return (
<section className="tarjeta">
<h2>Empleados</h2>
<div className="fila">
<input
aria-label="Buscar"
placeholder="Buscar..."
value={filtros.q}
onChange={(e) => dispatch({ type: 'busqueda_cambiada', q: e.target.value })}
/>
<select
aria-label="Orden"
value={filtros.orden}
onChange={(e) => dispatch({ type: 'orden_cambiado', orden: e.target.value })}
>
<option value="apellidos">Apellidos</option>
<option value="-salario">Salario descendente</option>
</select>
<button onClick={() => dispatch({ type: 'filtros_limpiados' })}>Limpiar</button>
</div>
<table>
<tbody>
{pagina?.contenido.map((e) => (
<tr key={e.empId}>
<td>
{e.apellidos}, {e.nombre}
</td>
<td>{e.salario} €</td>
</tr>
))}
</tbody>
</table>
<div className="fila separada">
<button
disabled={filtros.pagina <= 1}
onClick={() => dispatch({ type: 'pagina_cambiada', pagina: filtros.pagina - 1 })}
>
Anterior
</button>
<span id="info">
Página {pagina?.pagina} de {pagina?.paginas} ({pagina?.total} resultados)
</span>
<button
disabled={!pagina || filtros.pagina >= pagina.paginas}
onClick={() => dispatch({ type: 'pagina_cambiada', pagina: filtros.pagina + 1 })}
>
Siguiente
</button>
</div>
</section>
);
}
Probado contra la API: 24 resultados en 5 páginas. Tras ir a la página 2 y buscar garcia, se muestra “Página 1 de 1 (1 resultados)”.
La regla “cambiar el filtro vuelve a la página 1” está en una sola línea por cada caso. Con tres useState, tendrías que acordarte de llamar a setPagina(1) en cada manejador. Además, como filtros es un objeto nuevo en cada cambio real, sirve directamente como dependencia del efecto.
En una aplicación real, estos filtros irían en la URL (módulo 18) para poder compartir el enlace.
Ejercicio 3: Deshacer y rehacer
Escribe conHistorial, una función que reciba cualquier reductor y devuelva otro que añada deshacer y rehacer. Úsala con un presupuesto que se pueda aumentar y poner a cero. Una acción sin efecto no debe ocupar sitio en el historial, y una acción nueva debe borrar lo que se podía rehacer.
Ver solución
src/historial.ts:
export type Historial<S> = { pasado: S[]; presente: S; futuro: S[] };
export type AccionHistorial<A> = { type: 'deshacer' } | { type: 'rehacer' } | { type: 'accion'; accion: A };
export function conHistorial<S, A>(reducer: (estado: S, accion: A) => S) {
return function historialReducer(
{ pasado, presente, futuro }: Historial<S>,
accion: AccionHistorial<A>,
): Historial<S> {
switch (accion.type) {
case 'deshacer': {
if (pasado.length === 0) return { pasado, presente, futuro };
const anterior = pasado[pasado.length - 1];
return { pasado: pasado.slice(0, -1), presente: anterior, futuro: [presente, ...futuro] };
}
case 'rehacer': {
if (futuro.length === 0) return { pasado, presente, futuro };
const [siguiente, ...resto] = futuro;
return { pasado: [...pasado, presente], presente: siguiente, futuro: resto };
}
case 'accion': {
const nuevo = reducer(presente, accion.accion);
if (Object.is(nuevo, presente)) return { pasado, presente, futuro };
return { pasado: [...pasado, presente], presente: nuevo, futuro: [] };
}
}
};
}
export function iniciarHistorial<S>(presente: S): Historial<S> {
return { pasado: [], presente, futuro: [] };
}
src/historial.test.ts:
import { expect, it } from 'vitest';
import { conHistorial, iniciarHistorial } from './historial';
type Accion = { type: 'sumar'; n: number } | { type: 'nada' };
const reductor = conHistorial((estado: number, accion: Accion) =>
accion.type === 'sumar' ? estado + accion.n : estado,
);
it('deshace y rehace', () => {
let h = iniciarHistorial(0);
h = reductor(h, { type: 'accion', accion: { type: 'sumar', n: 5 } });
h = reductor(h, { type: 'accion', accion: { type: 'sumar', n: 2 } });
expect(h.presente).toBe(7);
h = reductor(h, { type: 'deshacer' });
expect(h.presente).toBe(5);
h = reductor(h, { type: 'rehacer' });
expect(h.presente).toBe(7);
});
it('una acción nueva borra el futuro', () => {
let h = iniciarHistorial(1);
h = reductor(h, { type: 'accion', accion: { type: 'sumar', n: 1 } });
h = reductor(h, { type: 'deshacer' });
h = reductor(h, { type: 'accion', accion: { type: 'sumar', n: 9 } });
expect(h).toEqual({ pasado: [1], presente: 10, futuro: [] });
});
it('una acción sin efecto no ensucia el historial', () => {
const h = reductor(iniciarHistorial(1), { type: 'accion', accion: { type: 'nada' } });
expect(h.pasado).toEqual([]);
});
src/App.tsx:
import { useReducer } from 'react';
import { conHistorial, iniciarHistorial } from './historial';
type Accion = { type: 'sumar'; cantidad: number } | { type: 'cero' };
function presupuestoReducer(estado: number, accion: Accion): number {
return accion.type === 'sumar' ? estado + accion.cantidad : 0;
}
const reductor = conHistorial(presupuestoReducer);
export default function App() {
const [historial, dispatch] = useReducer(reductor, iniciarHistorial(500000));
return (
<section className="tarjeta">
<h2>Presupuesto de DIRECCIÓN</h2>
<p className="dato">{historial.presente} €</p>
<div className="fila">
<button onClick={() => dispatch({ type: 'accion', accion: { type: 'sumar', cantidad: 10000 } })}>
+10 000
</button>
<button onClick={() => dispatch({ type: 'accion', accion: { type: 'cero' } })}>Poner a cero</button>
<button disabled={historial.pasado.length === 0} onClick={() => dispatch({ type: 'deshacer' })}>
Deshacer
</button>
<button disabled={historial.futuro.length === 0} onClick={() => dispatch({ type: 'rehacer' })}>
Rehacer
</button>
</div>
</section>
);
}
Esto es un reductor de orden superior: una función que recibe un reductor y devuelve otro. Funciona porque el reductor original es puro, y por tanto basta con guardar sus estados para volver atrás. Crea reductor fuera del componente: si lo creas dentro, cada render tiene una función distinta.
Ejercicio 4: Control por roles con use 🆕 19.0
🆕 Desde React 19.0. Este ejercicio usa
usey<Contexto>. No existen en React 18.3 ni anteriores. Si usas una versión anterior: usa<Contexto.Provider>, y lee conuseContextal principio del componente, antes deif (deshabilitado).
Crea un contexto de rol (ADMIN o USER), un componente <SoloRol rol alternativa> y un BotonBorrar que, si está deshabilitado, salga antes de leer el contexto. Después añade una sección “Ver como usuario”, que muestre la misma ficha como la vería un USER sin tocar el rol real.
Ver solución
import { createContext, use, useState } from 'react';
import type { ReactNode } from 'react';
type Rol = 'ADMIN' | 'USER';
const RolContexto = createContext<Rol>('USER');
type PropsSoloRol = { rol: Rol; alternativa?: ReactNode; children: ReactNode };
function SoloRol({ rol, alternativa = null, children }: PropsSoloRol) {
return use(RolContexto) === rol ? children : alternativa;
}
function BotonBorrar({ deshabilitado }: { deshabilitado: boolean }) {
if (deshabilitado) return <button disabled>Borrar</button>;
const rol = use(RolContexto);
return <button>{rol === 'ADMIN' ? 'Borrar definitivamente' : 'Solicitar borrado'}</button>;
}
function Ficha({ titulo }: { titulo: string }) {
return (
<section className="tarjeta">
<h2>{titulo}</h2>
<SoloRol rol="ADMIN" alternativa={<p className="tenue">Sin acceso al salario</p>}>
<p>Salario: 6500 €</p>
</SoloRol>
<div className="fila">
<BotonBorrar deshabilitado={false} />
<BotonBorrar deshabilitado />
</div>
</section>
);
}
export default function App() {
const [rol, setRol] = useState<Rol>('ADMIN');
return (
<RolContexto value={rol}>
<button onClick={() => setRol(rol === 'ADMIN' ? 'USER' : 'ADMIN')}>Cambiar rol ({rol})</button>
<div className="rejilla" style={{ marginTop: '1rem' }}>
<Ficha titulo="Vista normal" />
<RolContexto value="USER">
<Ficha titulo="Ver como usuario" />
</RolContexto>
</div>
</RolContexto>
);
}
Resultado real, resumido, con el rol ADMIN:
Vista normal: Salario: 6500 € / Borrar definitivamente
Ver como usuario: Sin acceso al salario / Solicitar borrado
“Ver como usuario” no necesita ningún código nuevo: basta anidar un proveedor, porque gana el más cercano. Ficha no se entera de nada.
⚠️ Importante: Esto solo decide qué se muestra. La seguridad real está en el servidor: la API EMPRESA rechaza un borrado con 403 si el rol no es
ADMIN, aunque alguien manipule la interfaz.
Ejercicio 5: Sistema de notificaciones
Crea un NotificacionesProvider y un hook useNotificar() para que cualquier componente pueda mostrar un aviso con notificar('Guardado'). Los avisos desaparecen solos a los 3 segundos. Los componentes que solo notifican no deben volver a renderizarse cuando cambia la lista de avisos.
Ver solución
src/notificaciones/contexto.ts:
import { createContext, use } from 'react';
export type TipoAviso = 'ok' | 'error';
export type Notificar = (texto: string, tipo?: TipoAviso) => void;
export const NotificarContexto = createContext<Notificar | null>(null);
export function useNotificar() {
const notificar = use(NotificarContexto);
if (!notificar) throw new Error('useNotificar debe usarse dentro de <NotificacionesProvider>');
return notificar;
}
src/notificaciones/NotificacionesProvider.tsx:
import { useCallback, useEffect, useReducer, useRef } from 'react';
import type { ReactNode } from 'react';
import { NotificarContexto } from './contexto';
import type { TipoAviso } from './contexto';
type Aviso = { id: string; texto: string; tipo: TipoAviso };
type Accion = { type: 'aviso_creado'; aviso: Aviso } | { type: 'aviso_retirado'; id: string };
function avisosReducer(avisos: Aviso[], accion: Accion): Aviso[] {
switch (accion.type) {
case 'aviso_creado':
return [...avisos, accion.aviso];
case 'aviso_retirado':
return avisos.filter((a) => a.id !== accion.id);
}
}
const DURACION_MS = 3000;
export function NotificacionesProvider({ children }: { children: ReactNode }) {
const [avisos, dispatch] = useReducer(avisosReducer, []);
const temporizadores = useRef(new Set<number>());
useEffect(() => {
const activos = temporizadores.current;
return () => {
activos.forEach(clearTimeout);
activos.clear();
};
}, []);
const notificar = useCallback((texto: string, tipo: TipoAviso = 'ok') => {
// El id y el temporizador nacen aquí: el reductor debe seguir siendo puro
const id = crypto.randomUUID();
dispatch({ type: 'aviso_creado', aviso: { id, texto, tipo } });
const temporizador = window.setTimeout(() => {
temporizadores.current.delete(temporizador);
dispatch({ type: 'aviso_retirado', id });
}, DURACION_MS);
temporizadores.current.add(temporizador);
}, []);
return (
<NotificarContexto value={notificar}>
{children}
<div role="status" style={{ position: 'fixed', right: 16, bottom: 16 }}>
{avisos.map((a) => (
<p key={a.id} className={`tarjeta ${a.tipo === 'error' ? 'alerta' : ''}`}>
{a.texto}
</p>
))}
</div>
</NotificarContexto>
);
}
src/App.tsx:
import { NotificacionesProvider } from './notificaciones/NotificacionesProvider';
import { useNotificar } from './notificaciones/contexto';
function Acciones() {
const notificar = useNotificar();
return (
<section className="tarjeta fila">
<button onClick={() => notificar('Empleado guardado')}>Guardar</button>
<button onClick={() => notificar('No se ha podido borrar', 'error')}>Borrar</button>
</section>
);
}
export default function App() {
return (
<NotificacionesProvider>
<Acciones />
</NotificacionesProvider>
);
}

Figura 16. Dos avisos activos. A los tres segundos desaparecen.
Hay tres claves. El contexto contiene solo notificar, una función estable: la lista la pinta el propio proveedor, y por eso los consumidores no se renderizan al cambiar. El id y el temporizador nacen en notificar, no en el reductor, para que siga siendo puro. Y el efecto limpia los temporizadores pendientes al desmontar.
Ejercicio 6: Arregla un contexto lento
Este proveedor reúne dos preferencias. Selector solo escribe la densidad, pero se renderiza también cuando cambia el idioma, a pesar de usar memo. Averigua por qué y arréglalo. Comprueba con un contador que Selector deja de renderizarse.
function PreferenciasProvider({ children }: { children: ReactNode }) {
const [idioma, setIdioma] = useState('es');
const [densidad, setDensidad] = useState('normal');
return (
<PreferenciasContexto value={{ idioma, densidad, setIdioma, setDensidad }}>
{children}
</PreferenciasContexto>
);
}
Ver solución
Hay dos problemas. El valor es un objeto nuevo en cada render, y Selector comparte contexto con datos que cambian, así que useMemo por sí solo no bastaría: el objeto debe cambiar cuando cambia el idioma, y Selector lo recibiría igualmente. Hay que separar lectura y escritura:
import { createContext, use, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useRenders } from './renders';
type Lectura = { idioma: string; densidad: string };
type Escritura = { setIdioma: (v: string) => void; setDensidad: (v: string) => void };
const LecturaContexto = createContext<Lectura | null>(null);
const EscrituraContexto = createContext<Escritura | null>(null);
function PreferenciasProvider({ children }: { children: ReactNode }) {
const [idioma, setIdioma] = useState('es');
const [densidad, setDensidad] = useState('normal');
const lectura = useMemo(() => ({ idioma, densidad }), [idioma, densidad]);
// Los set de useState son estables: este objeto se crea una sola vez
const escritura = useMemo(() => ({ setIdioma, setDensidad }), []);
return (
<EscrituraContexto value={escritura}>
<LecturaContexto value={lectura}>{children}</LecturaContexto>
</EscrituraContexto>
);
}
function Selector() {
const marca = useRenders();
const acciones = use(EscrituraContexto);
return (
<p>
<button onClick={() => acciones?.setDensidad('compacta')}>Compacta</button> Selector: <b ref={marca} id="b-s" />
</p>
);
}
function Traduccion() {
const marca = useRenders();
const lectura = use(LecturaContexto);
const acciones = use(EscrituraContexto);
return (
<p>
<button onClick={() => acciones?.setIdioma('en')}>Inglés</button> Idioma {lectura?.idioma}: <b ref={marca} id="b-t" />
</p>
);
}
export default function Bueno() {
return (
<PreferenciasProvider>
<Selector />
<Traduccion />
</PreferenciasProvider>
);
}
useRenders es el hook de la sección 14, en su propio fichero. Medición real tras pulsar “Inglés” y “Compacta”, con los dos marcadores arrancando en 2:
| Versión | Selector | Traduccion |
|---|---|---|
Antes, con memo | 4 | 4 |
Después, sin memo | 2 | 4 |
La versión nueva ni siquiera necesita memo. Traduccion sí sube, porque lee datos que cambian, y eso es correcto.
Ejercicio 7: Asistente de pedido por pasos
Crea un asistente de tres pasos (cliente, envío, confirmar) con un reductor. “Siguiente” debe validar el paso actual y no avanzar si hay errores. “Anterior” no debe perder los datos. Tras confirmar, muestra un resumen.
Ver solución
src/asistenteReducer.ts:
export type Datos = { cliente: string; direccion: string; urgente: boolean };
export type Paso = 'cliente' | 'envio' | 'confirmar' | 'hecho';
export type EstadoAsistente = {
paso: Paso;
datos: Datos;
errores: Partial<Record<keyof Datos, string>>;
};
export type AccionAsistente =
| { type: 'campo_cambiado'; cambios: Partial<Datos> }
| { type: 'siguiente' }
| { type: 'anterior' }
| { type: 'confirmado' };
const orden: Paso[] = ['cliente', 'envio', 'confirmar', 'hecho'];
export const inicioAsistente: EstadoAsistente = {
paso: 'cliente',
datos: { cliente: '', direccion: '', urgente: false },
errores: {},
};
function validar(paso: Paso, datos: Datos): EstadoAsistente['errores'] {
if (paso === 'cliente' && datos.cliente.trim() === '') return { cliente: 'Indica el cliente' };
if (paso === 'envio' && datos.direccion.trim().length < 5) {
return { direccion: 'La dirección es demasiado corta' };
}
return {};
}
export function asistenteReducer(estado: EstadoAsistente, accion: AccionAsistente): EstadoAsistente {
const posicion = orden.indexOf(estado.paso);
switch (accion.type) {
case 'campo_cambiado':
return { ...estado, datos: { ...estado.datos, ...accion.cambios }, errores: {} };
case 'siguiente': {
const errores = validar(estado.paso, estado.datos);
if (Object.keys(errores).length > 0) return { ...estado, errores };
return { ...estado, paso: orden[Math.min(posicion + 1, 2)], errores: {} };
}
case 'anterior':
return { ...estado, paso: orden[Math.max(posicion - 1, 0)], errores: {} };
case 'confirmado':
return estado.paso === 'confirmar' ? { ...estado, paso: 'hecho' } : estado;
}
}
src/App.tsx:
import { useReducer } from 'react';
import { asistenteReducer, inicioAsistente } from './asistenteReducer';
export default function App() {
const [{ paso, datos, errores }, dispatch] = useReducer(asistenteReducer, inicioAsistente);
if (paso === 'hecho') {
return <p className="tarjeta">Pedido registrado: {datos.cliente}</p>;
}
return (
<section className="tarjeta">
<h2>Nuevo pedido: paso {paso}</h2>
{paso === 'cliente' && (
<p>
<label>
Cliente{' '}
<input
value={datos.cliente}
onChange={(e) => dispatch({ type: 'campo_cambiado', cambios: { cliente: e.target.value } })}
/>
</label>
</p>
)}
{paso === 'envio' && (
<>
<p>
<label>
Dirección{' '}
<input
value={datos.direccion}
onChange={(e) => dispatch({ type: 'campo_cambiado', cambios: { direccion: e.target.value } })}
/>
</label>
</p>
<p>
<label>
<input
type="checkbox"
checked={datos.urgente}
onChange={(e) => dispatch({ type: 'campo_cambiado', cambios: { urgente: e.target.checked } })}
/>{' '}
Envío urgente
</label>
</p>
</>
)}
{paso === 'confirmar' && (
<p>
{datos.cliente}, {datos.direccion} {datos.urgente ? '(urgente)' : ''}
</p>
)}
{Object.values(errores).map((error) => (
<p key={error} className="alerta">
{error}
</p>
))}
<div className="fila">
<button disabled={paso === 'cliente'} onClick={() => dispatch({ type: 'anterior' })}>
Anterior
</button>
{paso === 'confirmar' ? (
<button onClick={() => dispatch({ type: 'confirmado' })}>Confirmar</button>
) : (
<button onClick={() => dispatch({ type: 'siguiente' })}>Siguiente</button>
)}
</div>
</section>
);
}
La validación está en el reductor porque decide la transición, y es pura. El estado de cada campo se conserva al cambiar de paso porque vive en el reductor y no en los <input>, que se desmontan. Probado: sin cliente no avanza, una dirección de 2 caracteres tampoco, y tras volver atrás la dirección sigue escrita.
El módulo 08 resolvió formularios multipaso con estado sencillo: compáralo.
Ejercicio 8 (propuesto): Cesta persistente
Haz que la cesta de la sección 13 sobreviva a una recarga. Usa el inicializador perezoso de useReducer para leer de localStorage, y un efecto para guardar. ¿Por qué no se puede guardar dentro del reductor?
Ejercicio 9 (propuesto): Confirmar el pedido
Une los contextos de sesión y de cesta. Añade un botón que envíe la cesta a POST /api/pedidos con el token de la sesión. Mira el formato del cuerpo en server.mjs, y añade acciones para el envío, el éxito (que vacía la cesta) y el error. Decide dónde debe vivir la llamada a la API, y justifica por qué no puede estar en el reductor.
Ejercicio 10 (propuesto): Tema con preferencia del sistema
Crea un TemaProvider con tres opciones: claro, oscuro y sistema. Con “sistema”, usa matchMedia('(prefers-color-scheme: dark)') y reacciona a sus cambios en un efecto. Expón useTema() y useCambiarTema() en contextos separados, y comprueba con un contador que el botón de cambio de tema no se renderiza al cambiar el tema.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Reductor | (estado, accion) => estado | Función pura que centraliza las transiciones |
| Hook del reductor | useReducer(reductor, inicial) | Devuelve [estado, dispatch] |
| Inicialización perezosa | useReducer(r, arg, iniciar) | iniciar(arg) se ejecuta una vez |
| Enviar una acción | dispatch({ type: ... }) | Encola; el reductor se ejecuta en el siguiente render |
| Bail out | Devolver el mismo objeto | Object.is igual: no se sigue hacia los hijos |
Identidad de dispatch | Estable | Se puede pasar a hijos con memo |
| Acciones tipadas | Unión discriminada | TypeScript distingue los datos por type |
| Exhaustividad | const x: never = accion | Error de compilación si falta un caso |
Tipar dispatch | Dispatch<Accion> | También ActionDispatch<[Accion]> en @types/react 19 |
| Crear un contexto | createContext<T>(defecto) | Fuera de los componentes |
| Proveer | <Contexto value={x}> 🆕 19.0 | Antes, <Contexto.Provider> |
| Leer | useContext(Contexto) | Gana el proveedor más cercano |
| Leer con flexibilidad | use(Contexto) 🆕 19.0 | Admite condiciones y bucles |
| Contexto seguro | createContext<T \| null>(null) y hook propio | Error claro si falta el proveedor |
| Reductor y contexto | Proveedor con dos contextos | Estado y dispatch por separado |
| Rendimiento | useMemo, separar contextos, children | Cambia value: se renderizan todos los consumidores |
| Nombre en DevTools | Contexto.displayName | Identifica el proveedor |
| Alternativas | Props, composición, Query, URL, Zustand | Context no es para todo |