La lección anterior terminó señalando el límite: ProveedorUsuario guardaba un dato simple, pero el estado de reservas de CicloUrbano tiene cuatro piezas que cambian juntas —la lista de reservas, el borrador en curso, la fase de envío y el posible error— y manejadores que tocan tres a la vez. Con useState eso se convierte en un puñado de variables sueltas, lógica de transición repartida por media docena de funciones y estados imposibles que nadie ha prohibido explícitamente. useReducer cambia el planteamiento: en lugar de modificar el estado desde muchos sitios, los componentes despachan acciones que describen lo que ha ocurrido, y una única función pura decide cómo pasa el estado de una forma a otra. En esta lección verás cuándo toca dar ese salto, qué es exactamente un reductor, cómo se diseñan las acciones, el caso completo de reductorReservas para CicloUrbano, cómo se prueba un reductor sin React y cómo combinarlo con useContext para compartir estado en todo el árbol.

Contenido

  1. Síntomas de que useState se ha quedado corto
  2. Qué es un reductor y qué significa que sea puro
  3. La firma de useReducer
  4. Anatomía de una acción y convención de nombres
  5. despachar es estable: por qué eso importa
  6. El flujo completo, paso a paso
  7. Caso CicloUrbano: reductorReservas al completo
  8. La tercera forma: inicialización perezosa
  9. useState frente a useReducer: tabla de decisión
  10. Probar un reductor sin React
  11. useReducer + useContext: estado compartido

  1. Síntomas de que useState se ha quedado corto

useReducer no es «el useState avanzado» ni una mejora automática. Es una herramienta para una situación concreta, y esta es la lista de síntomas que la anuncian.

Síntoma 1: muchos useState relacionados

// Panel de reservas de CicloUrbano con useState disperso
const [reservas, setReservas] = useState(reservasIniciales);
const [borrador, setBorrador] = useState(DATOS_INICIALES);
const [estadoEnvio, setEstadoEnvio] = useState('inactivo');
const [error, setError] = useState(null);

Cuatro variables que nunca se usan por separado: toda operación real toca al menos dos.

Síntoma 2: manejadores que tocan tres estados a la vez

function manejarConfirmar(idReserva) {
  setEstadoEnvio('enviando');
  setError(null);
  setReservas((previas) =>
    previas.map((r) => (r.id === idReserva ? { ...r, estado: 'confirmada' } : r))
  );
  setBorrador(DATOS_INICIALES);
  setEstadoEnvio('enviado');
}

El problema no es la longitud: es que la regla de negocio está repartida en cuatro llamadas, y si mañana confirmar una reserva también debe descontar una plaza de la estación, hay que acordarse de añadir la quinta. En todos los manejadores.

Síntoma 3: estados imposibles que nadie prohíbe

Nada impide que estadoEnvio valga 'enviando' y error tenga texto al mismo tiempo. La estructura lo permite y solo la disciplina lo evita. Con un reductor, las transiciones válidas se escriben una vez, en un solo sitio, y lo que no está escrito no puede ocurrir.

Síntoma 4: la misma lógica repetida en varios manejadores

Cancelar, caducar y rechazar una reserva hacen casi lo mismo. Con useState acabas copiando el map tres veces; con un reductor, las tres acciones caen en un switch donde la duplicación salta a la vista y se factoriza.

Regla práctica: si un cambio de estado necesita más de dos llamadas al actualizador, o si dos variables de estado nunca cambian por separado, prueba con useReducer.

  1. Qué es un reductor y qué significa que sea puro

Un reductor (reducer) es una función que recibe el estado actual y una acción, y devuelve el estado nuevo: (estadoPrevio, accion) => nuevoEstado

El nombre viene del método reduce de los arrays, que también toma un acumulador y un elemento y devuelve el acumulador siguiente. Un reductor de React hace lo mismo con el historial de acciones: si aplicaras todas las acciones desde el principio, obtendrías el estado actual.

// Un reductor mínimo, para ver la forma
function reductorHoras(horas, accion) {
  switch (accion.tipo) {
    case 'hora_añadida':
      return horas + 1;
    case 'hora_quitada':
      return Math.max(1, horas - 1);
    case 'horas_fijadas':
      return accion.horas;
    default:
      throw new Error(`Acción desconocida: ${accion.tipo}`);
  }
}

Un reductor debe ser puro, y aquí «puro» significa exactamente tres cosas:

Requisito Qué implica Qué NO puedes hacer dentro
Determinista Los mismos argumentos producen siempre el mismo resultado Math.random(), Date.now(), crypto.randomUUID()
Sin efectos secundarios No toca nada de fuera fetch, localStorage, console.log con contadores, mutar variables externas
Sin mutar los argumentos El estado previo se copia, no se modifica estado.reservas.push(x), estado.error = 'algo'
// ❌ IMPURO: fecha y aleatorio dentro del reductor
case 'reserva_creada':
  return {
    ...estado,
    reservas: [...estado.reservas, {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,   // ⚠️ no determinista
      creadaEn: new Date().toISOString()              // ⚠️ no determinista
    }]
  };

// ✅ PURO: los valores no deterministas llegan YA calculados dentro de la acción
case 'reserva_creada':
  return { ...estado, reservas: [...estado.reservas, accion.reserva] };

La regla se resuelve siempre igual: lo no determinista se calcula en el manejador y viaja dentro de la acción. Es el mismo criterio de 04-05 al generar el identificador de incidencia fuera del render.

¿Por qué tanta insistencia? Por tres motivos prácticos: React puede llamar al reductor dos veces con StrictMode para detectar impurezas; una función pura se puede probar sin montar nada (apartado 10); y un reductor puro hace que el estado sea reproducible, lo que permite herramientas de depuración que rebobinan el historial de acciones.

  1. La firma de useReducer

const [estado, despachar] = useReducer(reductor, estadoInicial);
Argumento / valor Qué es
reductor La función (estadoPrevio, accion) => nuevoEstado. Se declara fuera del componente
estadoInicial El estado del primer render
estado El estado del render en curso. Se lee, nunca se asigna
despachar Función que envía una acción al reductor y provoca un render

La simetría con useState es deliberada: los dos devuelven un par [valor, forma de cambiarlo] y los dos siguen la regla de la instantánea de 05-01. Llamar a despachar no cambia estado en el render en curso: encola la acción, React procesa la cola y el nuevo valor aparece en el render siguiente.

function manejarAñadirHora() {
  despachar({ tipo: 'hora_añadida' });
  console.log(estado.horas);   // el valor VIEJO: misma instantánea que con useState
}

El reductor se declara fuera del componente. No necesita nada del ámbito del componente —recibe todo lo que le hace falta como argumentos—, así que ponerlo dentro solo conseguiría recrearlo en cada render y complicar su prueba.

  1. Anatomía de una acción y convención de nombres

Una acción es un objeto plano que describe algo que ha ocurrido. Por convención lleva un campo identificador y los datos que necesite:

{ tipo: 'reserva_confirmada', idReserva: 'res-01' }
{ tipo: 'borrador_actualizado', campo: 'horas', valor: 4 }
{ tipo: 'envio_fallido', mensaje: 'El servidor respondió 503' }

En el ecosistema el campo se llama type (y en Redux es obligatorio que se llame así, como verás en 07-04). En este curso lo escribimos tipo porque no es una API de React sino una convención nuestra, y nuestros nombres van en español. Los datos adicionales se llaman genéricamente carga (payload): puedes ponerlos sueltos —como arriba— o agrupados en carga, siempre que seas coherente.

La regla de oro del nombrado

Nombra las acciones por lo que ha ocurrido, no por lo que hay que hacer.

❌ Nombre imperativo ✅ Nombre declarativo Por qué es mejor
poner_estado reserva_confirmada Dice qué pasó; el reductor decide las consecuencias
set_cargando envio_iniciado Una acción puede cambiar varias piezas a la vez
actualizar_reservas reserva_cancelada Se lee como un registro del negocio
limpiar_todo formulario_reiniciado Se entiende sin leer el reductor

La diferencia parece cosmética y no lo es. Con poner_estado el componente decide cómo cambia el estado, y la lógica vuelve a estar repartida: es useState con más ceremonia. Con reserva_confirmada, el componente solo informa del hecho y el reductor concentra todas las consecuencias. Si mañana confirmar también debe vaciar el borrador y registrar la hora, se toca una sola línea del reductor y todos los sitios que despachan esa acción quedan actualizados.

Una lista de acciones bien nombrada se lee como la crónica de lo que puede pasar en la aplicación:

reserva_creada · reserva_confirmada · reserva_cancelada
borrador_actualizado · borrador_reiniciado
envio_iniciado · envio_completado · envio_fallido

  1. despachar es estable: por qué eso importa

Igual que los actualizadores de useState (05-01), React garantiza que despachar es la misma función en todos los renders. Nunca se recrea. Tres consecuencias directas:

  • No entra en las dependencias de un efecto (05-02). Puedes despachar desde dentro de un efecto sin provocar reejecuciones.
  • No hay que envolverla en useCallback (08-03) para pasarla a un hijo memorizado: ya es estable.
  • Se puede meter en un contexto sin coste: el valor del contexto no cambia por su culpa (apartado 11).

Esto tiene un efecto de segundo orden muy valioso. Compara los dos efectos:

// Con useState: el efecto necesita el estado actual, así que depende de él
useEffect(() => {
  const identificador = setInterval(() => {
    setReservas((previas) => previas.map(caducarSiProcede));   // forma funcional obligatoria
  }, 60000);
  return () => clearInterval(identificador);
}, []);

// Con useReducer: el efecto no necesita saber NADA del estado
useEffect(() => {
  const identificador = setInterval(() => {
    despachar({ tipo: 'reservas_caducadas_revisadas' });
  }, 60000);
  return () => clearInterval(identificador);
}, []);   // ni una dependencia, y sin trucos

El segundo efecto es más limpio porque separa el disparador de la consecuencia: el temporizador solo anuncia que ha pasado un minuto; qué significa eso para las reservas lo decide el reductor. Es la forma más eficaz de simplificar efectos complicados.

  1. El flujo completo, paso a paso

flowchart TD
    A["El usuario pulsa «Confirmar»"] --> B["Manejador: manejarConfirmar(id)"]
    B --> C["despachar({ tipo: 'reserva_confirmada', idReserva: id })"]
    C --> D["React encola la acción"]
    D --> E["reductorReservas(estadoPrevio, accion)"]
    E --> F["Devuelve un OBJETO DE ESTADO NUEVO"]
    F --> G["React compara y programa un render"]
    G --> H["El componente se renderiza con el estado nuevo"]
    H --> I["Pantalla actualizada"]
    style C fill:#e0f2fe
    style E fill:#fde68a
    style I fill:#dcfce7

Lo que hace valioso este flujo es la separación de responsabilidades:

Pieza Responsabilidad Lo que NO hace
El componente Detectar la interacción y describir el hecho Decidir cómo queda el estado
La acción Transportar el hecho y sus datos Contener lógica
El reductor Aplicar todas las reglas de transición Tocar el DOM, la red o el reloj
React Renderizar con el estado resultante Interpretar las acciones

  1. Caso CicloUrbano: reductorReservas al completo

Vamos con el caso real. El estado gestiona las cuatro piezas del apartado 1.

// src/reductores/reservas.js
import { DATOS_INICIALES } from '../componentes/FormularioReserva.jsx';

export const ESTADO_INICIAL_RESERVAS = {
  reservas: [],                // lista de Reserva
  borrador: DATOS_INICIALES,   // { bicicletaId, fechaInicio, horas, condiciones }
  estadoEnvio: 'inactivo',     // 'inactivo' | 'enviando' | 'enviado' | 'error'
  error: null                  // mensaje de fallo, o null
};

/**
 * Reductor del panel de reservas de CicloUrbano.
 * Función PURA: (estadoPrevio, accion) => nuevoEstado
 */
export function reductorReservas(estado, accion) {
  switch (accion.tipo) {
    case 'borrador_actualizado':
      return {
        ...estado,
        borrador: { ...estado.borrador, [accion.campo]: accion.valor },
        error: null   // al escribir, se retira el error anterior
      };

    case 'borrador_reiniciado':
      return { ...estado, borrador: DATOS_INICIALES, error: null };

    case 'envio_iniciado':
      return { ...estado, estadoEnvio: 'enviando', error: null };

    case 'reserva_creada':
      return {
        ...estado,
        reservas: [...estado.reservas, accion.reserva],   // la reserva llega ya construida
        borrador: DATOS_INICIALES,
        estadoEnvio: 'enviado',
        error: null
      };

    case 'envio_fallido':
      return { ...estado, estadoEnvio: 'error', error: accion.mensaje };

    case 'reserva_confirmada':
      return {
        ...estado,
        reservas: estado.reservas.map((reserva) =>
          reserva.id === accion.idReserva ? { ...reserva, estado: 'confirmada' } : reserva
        )
      };

    case 'reserva_cancelada':
      return {
        ...estado,
        reservas: estado.reservas.map((reserva) =>
          reserva.id === accion.idReserva
            ? { ...reserva, estado: 'cancelada', canceladaEn: accion.momento }
            : reserva
        )
      };

    case 'panel_reiniciado':
      return { ...ESTADO_INICIAL_RESERVAS, reservas: estado.reservas };

    default:
      throw new Error(`reductorReservas: acción desconocida «${accion.tipo}»`);
  }
}

Comentarios sobre decisiones concretas del reductor:

  • Cada case devuelve un objeto nuevo con ...estado como base. Nunca se muta. Es el mismo recetario de inmutabilidad de 05-01, ahora concentrado en un solo fichero.
  • reserva_creada recibe la reserva ya construida. El identificador res-${crypto.randomUUID().slice(0, 8)} y la fecha se generan en el manejador, porque son no deterministas.
  • reserva_cancelada recibe accion.momento por el mismo motivo: new Date().toISOString() no puede vivir dentro del reductor.
  • Una acción cambia varias piezas a la vez. reserva_creada toca la lista, vacía el borrador, marca el envío como completado y limpia el error: cuatro cambios coherentes, imposibles de desincronizar porque ocurren en la misma expresión.
  • panel_reiniciado conserva las reservas y reinicia lo demás. El estado inicial se reutiliza, sin repetir literales.
  • El default lanza un error. Es deliberado: un return estado silencioso convertiría una errata como 'reseva_creada' en un fallo invisible que se manifestaría como «el botón no hace nada». Con el throw, el fallo aparece en el acto y —si has puesto un LimiteDeError (04-05)— con una interfaz alternativa decente.

El componente que lo usa

// src/componentes/PanelReservas.jsx
import { useReducer } from 'react';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from '../reductores/reservas.js';
import { validarReserva } from '../utilidades/validarReserva.js';
import Aviso from './Aviso.jsx';
import estilos from './PanelReservas.module.css';

/**
 * Panel de reservas de CicloUrbano.
 * Props:
 *  - bicicletas (array de Bicicleta, opcional, por defecto [])
 *  - usuarioId  (cadena, opcional, por defecto 'usr-01')
 */
function PanelReservas({ bicicletas = [], usuarioId = 'usr-01' }) {
  const [estado, despachar] = useReducer(reductorReservas, ESTADO_INICIAL_RESERVAS);
  const { reservas, borrador, estadoEnvio, error } = estado;

  // DERIVADOS: no son estado (05-01)
  const errores = validarReserva(borrador, bicicletas);
  const puedeEnviar = Object.keys(errores).length === 0 && estadoEnvio !== 'enviando';

  function manejarCambioCampo(campo, valor) {
    despachar({ tipo: 'borrador_actualizado', campo, valor });
  }

  async function manejarEnvio(evento) {
    evento.preventDefault();
    if (!puedeEnviar) return;

    // Lo no determinista se calcula AQUÍ, no en el reductor
    const reserva = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: borrador.bicicletaId,
      usuario: usuarioId,
      fechaInicio: borrador.fechaInicio,
      horas: borrador.horas,
      estado: 'activa'
    };

    despachar({ tipo: 'envio_iniciado' });
    try {
      await enviarReservaAlServidor(reserva);
      despachar({ tipo: 'reserva_creada', reserva });
    } catch (fallo) {
      despachar({ tipo: 'envio_fallido', mensaje: fallo.message });
    }
  }

  function manejarConfirmar(idReserva) {
    despachar({ tipo: 'reserva_confirmada', idReserva });
  }

  function manejarCancelar(idReserva) {
    despachar({
      tipo: 'reserva_cancelada',
      idReserva,
      momento: new Date().toISOString()
    });
  }

  return (
    <section className={estilos.panel}>
      <form noValidate onSubmit={manejarEnvio}>
        {/* campos controlados que llaman a manejarCambioCampo (03-04) */}
        <button type="submit" disabled={!puedeEnviar}>
          {estadoEnvio === 'enviando' ? 'Enviando…' : 'Crear reserva'}
        </button>
      </form>

      {estadoEnvio === 'error' && <Aviso tono="error">{error}</Aviso>}
      {estadoEnvio === 'enviado' && <Aviso tono="exito">Reserva creada correctamente.</Aviso>}

      <ul>
        {reservas.map((reserva) => (
          <li key={reserva.id}>
            {reserva.id} · {reserva.horas} h · {reserva.estado}
            <button type="button" onClick={() => manejarConfirmar(reserva.id)}>Confirmar</button>
            <button type="button" onClick={() => manejarCancelar(reserva.id)}>Cancelar</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

export default PanelReservas;

Compara este componente con la versión de cuatro useState del apartado 1. Los manejadores han pasado de coordinar varias llamadas a anunciar un hecho en una línea. Toda la lógica de transición está en un fichero aparte que se lee de arriba abajo como el manual de funcionamiento del panel. Y manejarEnvio deja clarísimo el reparto: lo asíncrono y lo no determinista viven en el componente; lo que le pasa al estado, en el reductor.

  1. La tercera forma: inicialización perezosa

useReducer acepta un tercer argumento: una función que calcula el estado inicial a partir del segundo argumento.

const [estado, despachar] = useReducer(reductorReservas, reservasGuardadas, crearEstadoInicial);
// src/reductores/reservas.js
export function crearEstadoInicial(reservasGuardadas) {
  return {
    ...ESTADO_INICIAL_RESERVAS,
    reservas: reservasGuardadas.filter((reserva) => reserva.estado !== 'cancelada')
  };
}

Es la misma idea que la inicialización perezosa de useState (05-01) y aporta lo mismo: el cálculo se ejecuta una sola vez, en el primer render, en lugar de en todos. Con la ventaja añadida de que la función de inicialización, al vivir fuera del componente, también se puede probar por separado y reutilizar en una acción de reinicio:

case 'panel_reiniciado':
  return crearEstadoInicial(accion.reservasGuardadas ?? []);

  1. useState frente a useReducer: tabla de decisión

Criterio useState useReducer
Número de piezas de estado Una, o varias independientes Varias que cambian juntas
Complejidad de las transiciones El nuevo valor sale de una expresión Reglas, condiciones, varias piezas por cambio
Dónde vive la lógica Repartida por los manejadores Concentrada en una función
Legibilidad de los manejadores Empeora al crecer Una línea por manejador
Testabilidad Requiere montar el componente Función pura: se prueba sola
Trazabilidad Hay que buscar quién llama a cada set La lista de acciones documenta lo que puede pasar
Estados imposibles Posibles si no hay disciplina Difíciles: solo existe lo que el reductor escribe
Dependencias de efectos El estado suele entrar en [deps] despachar es estable: efectos más limpios
Curva de lectura Inmediata Hay que ir al reductor para entender el efecto de una acción
Líneas para un contador 1 ~10

Dos advertencias para no pasarse de frenada:

  • No conviertas todo a useReducer. Para un panel plegado/desplegado o el texto de un buscador, useState es más corto y más claro. Un reductor para un booleano es ceremonia pura.
  • Puedes mezclarlos en el mismo componente. Lo habitual es un useReducer para el asunto complejo y algún useState para lo local y trivial.

  1. Probar un reductor sin React

Esta es una de las mayores ventajas prácticas y merece verse aunque las pruebas sean el Módulo 9. Un reductor es una función pura: no necesita navegador, ni DOM, ni renderizar nada.

// src/reductores/reservas.test.js  (sintaxis de Vitest/Jest — Módulo 9)
import { describe, it, expect } from 'vitest';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from './reservas.js';

describe('reductorReservas', () => {
  it('crea una reserva, vacía el borrador y marca el envío como completado', () => {
    const reserva = {
      id: 'res-02',
      bicicletaId: 'bici-001',
      usuario: 'usr-01',
      fechaInicio: '2026-05-05T10:00',
      horas: 3,
      estado: 'activa'
    };

    const resultado = reductorReservas(
      { ...ESTADO_INICIAL_RESERVAS, estadoEnvio: 'enviando' },
      { tipo: 'reserva_creada', reserva }
    );

    expect(resultado.reservas).toHaveLength(1);
    expect(resultado.estadoEnvio).toBe('enviado');
    expect(resultado.borrador.bicicletaId).toBe('');
    expect(resultado.error).toBeNull();
  });

  it('no muta el estado que recibe', () => {
    const previo = { ...ESTADO_INICIAL_RESERVAS, reservas: [] };
    reductorReservas(previo, { tipo: 'reserva_creada', reserva: { id: 'res-03' } });

    expect(previo.reservas).toHaveLength(0);   // el original sigue intacto
  });

  it('lanza ante una acción desconocida', () => {
    expect(() => reductorReservas(ESTADO_INICIAL_RESERVAS, { tipo: 'inventada' })).toThrow();
  });
});

Sin montar un solo componente has verificado tres reglas de negocio y la inmutabilidad. Con la lógica repartida en manejadores dentro del componente, cada una de estas comprobaciones exigiría renderizar, simular clics y esperar. Es la razón principal por la que los equipos grandes extraen la lógica de estado a reductores: el código puro se prueba barato. El detalle de las herramientas llega en 09-02.

  1. useReducer + useContext: estado compartido

Combinando lo de esta lección con lo de la anterior sale un patrón muy potente: el estado complejo vive en un reductor, el reductor vive en un proveedor, y cualquier componente del árbol puede leer el estado y despachar acciones sin recibir una sola prop.

// src/contextos/ContextoReservas.jsx
import { createContext, useContext, useReducer } from 'react';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from '../reductores/reservas.js';

const ContextoReservas = createContext(null);

/**
 * Proveedor del estado de reservas de CicloUrbano.
 * Props:
 *  - children (contenido)
 */
export function ProveedorReservas({ children }) {
  const [estado, despachar] = useReducer(reductorReservas, ESTADO_INICIAL_RESERVAS);

  return (
    <ContextoReservas value={{ estado, despachar }}>
      {children}
    </ContextoReservas>
  );
}

export function useReservas() {
  const contexto = useContext(ContextoReservas);
  if (contexto === null) {
    throw new Error('useReservas debe usarse dentro de <ProveedorReservas>');
  }
  return contexto;
}

Y cualquier componente, a cualquier profundidad:

// src/componentes/ContadorReservasActivas.jsx
import { useReservas } from '../contextos/ContextoReservas.jsx';

function ContadorReservasActivas() {
  const { estado } = useReservas();
  const activas = estado.reservas.filter((reserva) => reserva.estado === 'activa').length;

  return <span>Reservas activas: {activas}</span>;
}
// src/componentes/BotonCancelarReserva.jsx
import { useReservas } from '../contextos/ContextoReservas.jsx';

function BotonCancelarReserva({ idReserva }) {
  const { despachar } = useReservas();

  return (
    <button
      type="button"
      onClick={() =>
        despachar({ tipo: 'reserva_cancelada', idReserva, momento: new Date().toISOString() })
      }
    >
      Cancelar
    </button>
  );
}
flowchart TD
    PR["&lt;ProveedorReservas&gt;<br/>useReducer(reductorReservas)"] --> DIS["Diseno"]
    DIS --> CAB["Cabecera"]
    CAB --> CNT["ContadorReservasActivas<br/>lee estado"]
    DIS --> PAN["PanelReservas"]
    PAN --> BOT["BotonCancelarReserva<br/>despacha acciones"]
    BOT -. "despachar" .-> PR
    PR -. "estado" .-> CNT
    style PR fill:#fde68a
    style CNT fill:#dcfce7
    style BOT fill:#e0f2fe

Si has oído hablar de Redux, acabas de escribir su modelo mental completo: un estado centralizado, acciones que describen hechos y reductores puros que calculan el estado siguiente. Redux añade herramientas alrededor —un almacén único fuera de React, middleware para lo asíncrono, extensiones de depuración que rebobinan acciones, y utilidades como Redux Toolkit—, pero el núcleo es exactamente esto. La comparación completa y cuándo compensa cada opción se ve en 07-01 y 07-03. Aquí lo importante es que ya entiendes el mecanismo, así que Redux no te resultará un misterio sino un empaquetado de algo conocido.

Un apunte de rendimiento, en la misma línea que el de 05-04: al meter { estado, despachar } en un contexto, cualquier cambio de estado repinta a todos los consumidores, incluidos los que solo despachan y nunca leen. La solución habitual —separar el estado y el despachador en dos contextos— pertenece a 07-02.

Errores Comunes y Consejos

  • Mutar el estado dentro del reductor. estado.reservas.push(nueva); return estado; no repinta: la referencia es la misma. Copia siempre.
  • Meter código no determinista o efectos en el reductor. Date.now(), crypto.randomUUID(), fetch o localStorage rompen la pureza. Calcula en el manejador y pásalo en la acción.
  • Un default que devuelve el estado en silencio. Convierte una errata en un botón que no hace nada. Lanza un error con el tipo recibido.
  • Nombrar las acciones en imperativo (poner_error, set_reservas). La lógica vuelve al componente y pierdes toda la ventaja.
  • Declarar el reductor dentro del componente. Se recrea en cada render, no se puede probar por separado y sugiere que depende del ámbito, cosa que no debe.
  • Convertir a useReducer un estado simple. Diez líneas para un booleano no mejoran nada.
  • Leer el estado justo después de despachar. Sigue vigente la instantánea de 05-01: despachar no cambia estado en el render en curso.
  • Olvidar ...estado al devolver. return { reservas: [...] }; borra el borrador, el estado de envío y el error de un plumazo.
  • Consejo: escribe primero la lista de acciones, antes que el reductor. Si esa lista se lee como la crónica de lo que puede ocurrir en la pantalla, el diseño es bueno.
  • Consejo: si un case se repite casi igual que otro, extrae una función auxiliar pura y llámala desde ambos. El reductor puede apoyarse en otras funciones puras sin problema.

Ejercicios

Ejercicio 1. Este reductor de CicloUrbano tiene cuatro fallos. Encuéntralos, explica el problema de cada uno y escribe la versión corregida.

function reductorFlota(estado, accion) {
  switch (accion.tipo) {
    case 'bicicleta_añadida':
      estado.bicicletas.push({ id: `bici-${Math.random()}`, ...accion.datos });
      return estado;

    case 'bicicleta_a_taller':
      return {
        bicicletas: estado.bicicletas.map((b) =>
          b.id === accion.id ? { ...b, estado: 'mantenimiento' } : b
        )
      };

    case 'flota_recargada':
      fetch('/api/bicicletas').then((r) => r.json()).then((d) => (estado.bicicletas = d));
      return estado;

    default:
      return estado;
  }
}

Ejercicio 2. Convierte a useReducer este componente de CicloUrbano. Define el estado inicial, escribe el reductor completo con nombres de acción declarativos y reescribe los manejadores.

function SelectorReserva({ bicicletas }) {
  const [idBicicleta, setIdBicicleta] = useState(null);
  const [horas, setHoras] = useState(2);
  const [confirmada, setConfirmada] = useState(false);
  const [error, setError] = useState(null);

  function manejarSeleccion(id) {
    setIdBicicleta(id);
    setHoras(2);
    setConfirmada(false);
    setError(null);
  }

  function manejarCambioHoras(nuevas) {
    if (nuevas < 1 || nuevas > 24) {
      setError('Las reservas van de 1 a 24 horas.');
      return;
    }
    setHoras(nuevas);
    setError(null);
    setConfirmada(false);
  }

  function manejarConfirmar() {
    if (!idBicicleta) {
      setError('Elige una bicicleta antes de confirmar.');
      return;
    }
    setConfirmada(true);
    setError(null);
  }
  …
}

Ejercicio 3. Escribe tres pruebas del reductorReservas del apartado 7, sin renderizar nada: que borrador_actualizado cambia solo el campo indicado y conserva los demás; que envio_fallido guarda el mensaje y deja las reservas intactas; y que panel_reiniciado conserva la lista de reservas pero vacía el borrador y el error.

Soluciones

Solución 1.

Los cuatro fallos:

  1. bicicleta_añadida muta el estado con push y devuelve la misma referencia. React no ve ningún cambio y no repinta.
  2. Math.random() dentro del reductor rompe el determinismo. El identificador debe generarse en el manejador y llegar en la acción.
  3. bicicleta_a_taller no copia el resto del estado. Devuelve un objeto con solo bicicletas, así que cualquier otra pieza (filtros, selección, error) se pierde.
  4. flota_recargada hace fetch y muta el estado en el then. Un efecto secundario dentro del reductor, además asíncrono: cuando la respuesta llegue, ese objeto de estado hará mucho que se descartó. La carga de datos va en un efecto (05-02) o en el manejador, y el resultado se despacha como acción.

Añadido: el default que devuelve el estado en silencio oculta erratas en los tipos.

export const ESTADO_INICIAL_FLOTA = { bicicletas: [], cargando: false, error: null };

export function reductorFlota(estado, accion) {
  switch (accion.tipo) {
    case 'bicicleta_añadida':
      // La bicicleta llega ya construida, con su id generado fuera
      return { ...estado, bicicletas: [...estado.bicicletas, accion.bicicleta] };

    case 'bicicleta_a_taller':
      return {
        ...estado,
        bicicletas: estado.bicicletas.map((bicicleta) =>
          bicicleta.id === accion.id ? { ...bicicleta, estado: 'mantenimiento' } : bicicleta
        )
      };

    case 'recarga_iniciada':
      return { ...estado, cargando: true, error: null };

    case 'flota_recargada':
      // El fetch lo hace el componente; aquí solo llegan los datos
      return { ...estado, bicicletas: accion.bicicletas, cargando: false, error: null };

    case 'recarga_fallida':
      return { ...estado, cargando: false, error: accion.mensaje };

    default:
      throw new Error(`reductorFlota: acción desconocida «${accion.tipo}»`);
  }
}

Y el manejador, con lo asíncrono y lo no determinista donde corresponde:

function manejarAñadir(datos) {
  despachar({
    tipo: 'bicicleta_añadida',
    bicicleta: { id: `bici-${crypto.randomUUID().slice(0, 8)}`, ...datos }
  });
}

async function manejarRecargar() {
  despachar({ tipo: 'recarga_iniciada' });
  try {
    const respuesta = await fetch('/api/bicicletas');
    if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
    despachar({ tipo: 'flota_recargada', bicicletas: await respuesta.json() });
  } catch (fallo) {
    despachar({ tipo: 'recarga_fallida', mensaje: fallo.message });
  }
}

Solución 2.

// src/reductores/selectorReserva.js
export const ESTADO_INICIAL_SELECTOR = {
  idBicicleta: null,
  horas: 2,
  confirmada: false,
  error: null
};

const HORAS_MINIMAS = 1;
const HORAS_MAXIMAS = 24;

export function reductorSelector(estado, accion) {
  switch (accion.tipo) {
    case 'bicicleta_elegida':
      // Elegir bicicleta reinicia toda la reserva en curso
      return { ...ESTADO_INICIAL_SELECTOR, idBicicleta: accion.id };

    case 'horas_cambiadas':
      if (accion.horas < HORAS_MINIMAS || accion.horas > HORAS_MAXIMAS) {
        return {
          ...estado,
          error: `Las reservas van de ${HORAS_MINIMAS} a ${HORAS_MAXIMAS} horas.`
        };
      }
      return { ...estado, horas: accion.horas, error: null, confirmada: false };

    case 'reserva_confirmada':
      if (!estado.idBicicleta) {
        return { ...estado, error: 'Elige una bicicleta antes de confirmar.' };
      }
      return { ...estado, confirmada: true, error: null };

    default:
      throw new Error(`reductorSelector: acción desconocida «${accion.tipo}»`);
  }
}
// src/componentes/SelectorReserva.jsx
import { useReducer } from 'react';
import { reductorSelector, ESTADO_INICIAL_SELECTOR } from '../reductores/selectorReserva.js';

function SelectorReserva({ bicicletas = [] }) {
  const [estado, despachar] = useReducer(reductorSelector, ESTADO_INICIAL_SELECTOR);
  const { idBicicleta, horas, confirmada, error } = estado;

  function manejarSeleccion(id) {
    despachar({ tipo: 'bicicleta_elegida', id });
  }

  function manejarCambioHoras(nuevas) {
    despachar({ tipo: 'horas_cambiadas', horas: nuevas });
  }

  function manejarConfirmar() {
    despachar({ tipo: 'reserva_confirmada' });
  }
  …
}

Lo que se ha ganado: los tres manejadores son de una línea; las reglas —los límites de 1 a 24 horas, que elegir bicicleta reinicia la reserva, que confirmar sin bicicleta es un error— están escritas en un solo fichero y se leen seguidas; los límites son constantes con nombre en lugar de números sueltos; y bicicleta_elegida reutiliza ESTADO_INICIAL_SELECTOR en vez de repetir cuatro asignaciones, de modo que si mañana se añade una quinta pieza al estado, el reinicio la incluye sin tocar nada.

Solución 3.

import { describe, it, expect } from 'vitest';
import { reductorReservas, ESTADO_INICIAL_RESERVAS } from './reservas.js';

describe('reductorReservas', () => {
  it('borrador_actualizado cambia solo el campo indicado', () => {
    const previo = {
      ...ESTADO_INICIAL_RESERVAS,
      borrador: { bicicletaId: 'bici-001', fechaInicio: '2026-05-04T09:00', horas: 2, condiciones: false }
    };

    const resultado = reductorReservas(previo, {
      tipo: 'borrador_actualizado',
      campo: 'horas',
      valor: 5
    });

    expect(resultado.borrador.horas).toBe(5);
    expect(resultado.borrador.bicicletaId).toBe('bici-001');   // los demás intactos
    expect(resultado.borrador.fechaInicio).toBe('2026-05-04T09:00');
    expect(previo.borrador.horas).toBe(2);                     // sin mutación
  });

  it('envio_fallido guarda el mensaje y no toca las reservas', () => {
    const previo = {
      ...ESTADO_INICIAL_RESERVAS,
      reservas: [{ id: 'res-01', horas: 2, estado: 'activa' }],
      estadoEnvio: 'enviando'
    };

    const resultado = reductorReservas(previo, {
      tipo: 'envio_fallido',
      mensaje: 'El servidor respondió 503'
    });

    expect(resultado.estadoEnvio).toBe('error');
    expect(resultado.error).toBe('El servidor respondió 503');
    expect(resultado.reservas).toBe(previo.reservas);   // misma referencia: no se ha copiado
  });

  it('panel_reiniciado conserva las reservas y limpia lo demás', () => {
    const previo = {
      reservas: [{ id: 'res-01', horas: 2, estado: 'activa' }],
      borrador: { bicicletaId: 'bici-003', fechaInicio: '2026-05-06T08:00', horas: 6, condiciones: true },
      estadoEnvio: 'error',
      error: 'Fallo anterior'
    };

    const resultado = reductorReservas(previo, { tipo: 'panel_reiniciado' });

    expect(resultado.reservas).toHaveLength(1);
    expect(resultado.borrador.bicicletaId).toBe('');
    expect(resultado.estadoEnvio).toBe('inactivo');
    expect(resultado.error).toBeNull();
  });
});

Fíjate en la segunda prueba: expect(resultado.reservas).toBe(previo.reservas) comprueba la identidad, no la igualdad. Es una afirmación deliberada: envio_fallido no debe copiar la lista, porque copiarla sin necesidad generaría una referencia nueva y obligaría a repintar a todos los componentes memorizados que dependen de ella (Módulo 8). Un reductor bien escrito conserva las referencias de lo que no cambia.

Conclusión

useReducer es la respuesta cuando el estado deja de ser un dato y pasa a ser un sistema: varias piezas que cambian juntas, transiciones con reglas y manejadores que se vuelven ilegibles. La idea central es un cambio de responsabilidades: el componente ya no decide cómo queda el estado, solo despacha acciones que describen lo que ha ocurrido, y una función pura —el reductor— concentra todas las reglas. Has visto la firma useReducer(reductor, estadoInicial) y su tercera forma con inicialización perezosa; la anatomía de las acciones y por qué nombrarlas por el hecho (reserva_confirmada) y no por la operación (poner_estado) es lo que evita que la lógica se vuelva a dispersar; que despachar es estable y con ello simplifica dependencias de efectos y memorización; el reductorReservas completo de CicloUrbano con su switch, su inmutabilidad y su default que lanza; y dos ventajas que se cobran a diario: un reductor se prueba sin React, por ser puro, y se combina con useContext para dar a todo el árbol acceso al estado y a las acciones. Ese último patrón es, literalmente, el modelo mental de Redux, que el Módulo 7 retomará con su comparación completa.

Con esto has recorrido los cinco hooks fundamentales de React: useState, useEffect, useRef, useContext y useReducer. Pero queda la pieza que da sentido a todo el diseño de los hooks y que 04-04 anunció como su razón de ser histórica: reutilizar lógica con estado sin envoltorios. El buscador con retardo del ejercicio de 05-02, la suscripción al evento de conexión, el temporizador de disponibilidad, el estado sincronizado con localStorage del tema… todo eso son piezas que se repiten en distintos componentes y que hoy tendrías que copiar y pegar. La próxima lección convierte esa lógica en funciones reutilizables que se llaman igual que los hooks de React porque son hooks. La próxima lección es Hooks Personalizados.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados