Módulo 11: useReducer y Context

Objetivos del módulo

  • Reconocer cuándo el estado de un componente ha crecido tanto que useState se queda corto
  • Escribir reductores y entender por qué useReducer separa 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 con use(Contexto), y saber qué hacer en versiones anteriores
  • Construir un contexto seguro con null como 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 useMemo o separando contextos
  • Decidir cuándo no usar Context

⚠️ Importante: useReducer, createContext y useContext funcionan 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}>, y use(Contexto) pasa a ser useContext(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 useState relacionados pueden contradecirse. Si cargando, error y datos son tres estados distintos, nada impide que cargando y error esté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>
  );
}

Tarjeta de stock con 2 unidades y el aviso Por debajo del mínimo

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.

Ciclo: el componente llama a dispatch, la acción entra en la cola, el reductor calcula el estado nuevo y React renderiza

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. Con useReducer(totalReducer, leerGuardado('cesta')) la función se ejecutaría en cada render. Es el mismo error que con useState.

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:

  1. dispatch no 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. dispatch solo deja la acción en una cola y pide un render.
  2. El reductor se ejecuta durante el render siguiente. Es parte de renderizar, y por eso debe ser puro.
  3. El estado es una instantánea. Dentro del manejador, n vale 0 antes y después de los dispatch, como con useState (módulo 05). Si necesitas el valor siguiente, calcúlalo antes o muévelo al reductor.
  4. 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() ni crypto.randomUUID() dentro.
  • Sin efectos secundarios. Nada de fetch, localStorage, console.log en 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>
  );
}

Dos tarjetas tras un solo clic en cada una: el reductor mutante muestra dos líneas y el puro muestra una

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 return en ninguna rama. En JavaScript, o si escribes el reductor sin tipos, una acción no tratada hace que la función devuelva undefined, 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 ser undefined y 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/react 19 cambian los genéricos de useReducer: ahora son useReducer<Estado, [Accion]>, y ya no useReducer<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/react 18, usa useReducer<Reducer<Estado, Accion>>, y Dispatch<Accion> para tipar dispatch, porque ActionDispatch no 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_anadido delega en cantidad_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 totalCesta y unidadesCesta son 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>
  );
}

Catálogo con tres productos y cesta con tres líneas, siete unidades y un total de 2897,50 euros

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 hacer push, 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>
  );
}

Tabla con los cinco empleados mejor pagados

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

La misma tarjeta con el texto La API respondió 404 en lugar de la tabla

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:

  • cargaReducer es genérico, y cargaReducer<Pagina<Empleado>> fija el tipo con una expresión de instanciación de TypeScript.
  • Al usar estado.datos dentro de estado.fase === 'exito', TypeScript sabe que datos existe. 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 fase recargando con datos.
  • dispatch funciona igual después de un await, y es seguro, porque no depende de ninguna instantánea.
  • peticion_iniciada se 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 el catch ignora 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.

Dos árboles. A la izquierda, la prop usuario atraviesa Pagina y Barra hasta Avatar. A la derecha, el contexto salta directamente de App a Avatar

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>
    </>
  );
}

Panel Empleados y panel anidado en oscuro, con un tercer panel claro debajo

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:

  1. 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.
  2. Los consumidores se actualizan solos. Al pulsar el botón, Panel y Boton cambian sin recibir ninguna prop. React los busca y los vuelve a renderizar.
  3. El valor por defecto solo se usa sin proveedor. “Fuera de cualquier proveedor” usa 'claro', el argumento de createContext, y nunca cambia.
  4. 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 div externo.

Cajas anidadas: el proveedor exterior contiene dos componentes y un segundo proveedor con otros dos; un panel queda fuera y usa el valor por defecto

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 createContext dentro 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 con render 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 exporta use. Si usas una versión anterior: lee con useContext(Contexto) al principio del componente, antes de cualquier return condicional. 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? useContext sigue siendo correcto y no está obsoleto. Si tu proyecto solo usa React 19, use cubre además las lecturas condicionales. Lo que importa es elegir un criterio para no confundir al que lea el código. En este curso usamos useContext en los ejemplos básicos, y use dentro 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:

  1. Crear el contexto con null, para que “no hay proveedor” sea detectable.
  2. Escribir un componente proveedor que guarde el estado y la lógica.
  3. 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>
  );
}

Formulario de inicio de sesión con el mensaje Email o contraseña incorrectos

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

Cabecera con el texto Hola, Ana García (ADMIN) y un botón para cerrar sesión

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 de react. En @types/react 19.3.0, FormEvent está marcado como obsoleto. No olvides el import type: sin él, TypeScript usa el SubmitEvent global del DOM, que es otro tipo y no admite ese parámetro genérico. En @types/react 18.3.31 no existe SubmitEvent: allí usa FormEvent<HTMLFormElement>.

Hay mucho que comentar aquí:

  • dispatch tras un await. Es correcto. Una función async no puede ser un reductor, porque un reductor es puro y síncrono. Por eso entrar vive 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.
  • useCallback y useMemo. Sirven para que valor no 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:

El CestaProvider contiene useReducer y alimenta dos contextos; Insignia lee la cesta, BotonAnadir lee dispatch y Resumen lee ambos

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>
  );
}

Tienda con cabecera, insignia con el número 3, catálogo de cinco productos y resumen de la cesta con un total de 4797 euros

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 value de un proveedor, comparado con Object.is, todos sus consumidores se vuelven a renderizar. memo no 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:

Cuatro tarjetas con contadores. A: 7. B: 2. C: 7 y 7. D: 2, 7 y 2

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

Comparación: con un contexto se renderizan el botón y el lector; con dos contextos solo el lector

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 a contexto.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 use y <Contexto>. No existen en React 18.3 ni anteriores. Si usas una versión anterior: usa <Contexto.Provider>, y lee con useContext al principio del componente, antes de if (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>
  );
}

Dos avisos en la esquina inferior derecha: Empleado guardado y No se ha podido borrar

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