La interfaz está terminada y no hace nada. Las bicicletas salen de un fichero, el filtro se olvida al recargar, el formulario no envía, la sesión no existe y RutaProtegida deja pasar a cualquiera. Esta lección conecta los cables: al terminar, CicloUrbano será una aplicación de verdad, con datos que viajan por la red, se cachean, se invalidan y se muestran con sus estados de carga y de error en el sitio correcto.

El trabajo se organiza de dentro hacia fuera. Primero una capa de acceso a datos que no sabe nada de React y que centraliza la URL base, las cabeceras, el manejo de errores HTTP y el tiempo de espera. Encima, TanStack Query con sus claves jerárquicas, sus valores por defecto elegidos a conciencia y las mutaciones del proyecto —incluida la actualización optimista con su reversión. Al lado, Redux Toolkit para lo que sí es estado de cliente, contexto para tema y avisos, y la URL para el filtro. Y por encima de todo, la pregunta que decide la calidad percibida de una aplicación: cuando algo falla, quién muestra el error.

Nada de lo que viene es nuevo conceptualmente; todo se explicó en el módulo 7. Lo nuevo es que aquí se aplica sobre un proyecto completo, donde las decisiones se rozan entre sí y hay que resolver los conflictos.

Contenido

  1. La tabla definitiva: qué dato vive dónde
  2. La capa de acceso a datos: src/api/cliente.js
  3. Funciones por recurso
  4. Por qué esta capa no sabe nada de React
  5. clienteConsultas.js y sus valores por defecto
  6. La fábrica de claves
  7. Los hooks de consulta del proyecto
  8. Conectar el catálogo: de los interruptores a los estados reales
  9. La mutación completa: crear una reserva
  10. Actualización optimista: confirmar y cancelar
  11. Redux: la sesión
  12. Redux: el catálogo, y qué no entra en Redux
  13. Contexto: tema y avisos
  14. El filtro en la URL con useSearchParams
  15. Rutas protegidas conectadas a la sesión real
  16. Manejo de errores de punta a punta
  17. main.jsx definitivo
  18. El flujo completo de una reserva
  19. Comprobación manual del recorrido

  1. La tabla definitiva: qué dato vive dónde

El acta de 11-01 fijó el criterio; esto es su aplicación dato por dato. Es la tabla que se consulta cada vez que aparece un estado nuevo, y la que evita la discusión de siempre.

Dato Dónde vive Por qué ahí y no en otro sitio
Lista de bicicletas TanStack Query Vive en el servidor; necesita caché, revalidación e invalidación tras mutar
Ficha de una bicicleta TanStack Query Ídem, con clave de detalle propia
Estaciones y su detalle TanStack Query Ídem. Cambian poquísimo: staleTime alto
Reservas del usuario TanStack Query Ídem, con clave dependiente del usuario
Usuario identificado Redux (sliceSesion) Lo leen la cabecera, las rutas protegidas y el formulario de reserva. No es una copia de un recurso remoto: es el estado de esta sesión
Término de búsqueda Redux (sliceCatalogo) Lo escribe el buscador y lo lee la lista; sobrevive a navegar a una ficha y volver
Criterio de orden Redux (sliceCatalogo) Ídem, y es una preferencia del usuario dentro de la sesión
Filtro por tipo URL (?tipo=) Debe ser compartible por enlace y sobrevivir a la recarga (H2)
Pestaña activa de estación URL (segmento de ruta) Ídem
Tema claro/oscuro Contexto + localStorage Cambia poco, lo necesita todo el árbol
Avisos temporales Contexto Los emite cualquier pantalla y los pinta el marco
Modal de confirmación abierto useState local Nadie fuera de la pantalla necesita saberlo
Borrador del formulario useState local Se descarta al salir; guardarlo en Redux solo añadiría acciones
Estado de envío de una mutación TanStack Query (isPending) Lo da la mutación; duplicarlo en useState produce desincronización

Las dos filas que más se equivocan en proyectos reales son la primera y la última. Meter la lista de bicicletas en Redux obliga a reimplementar caché, deduplicación y revalidación (07-06). Duplicar isPending en un useState produce el clásico botón que se queda girando para siempre porque alguien olvidó ponerlo a false en la rama de error.

flowchart TD
    subgraph SERVIDOR["Estado del servidor · TanStack Query"]
        Q1["bicicletas"]
        Q2["estaciones"]
        Q3["reservas"]
    end
    subgraph CLIENTE["Estado de cliente · Redux Toolkit"]
        R1["sliceSesion: usuario"]
        R2["sliceCatalogo: termino, orden"]
    end
    subgraph URL["Estado de URL · React Router"]
        U1["?tipo=electrica"]
        U2["/estaciones/est-01/incidencias"]
    end
    subgraph CONTEXTO["Interfaz global · Contexto"]
        C1["tema"]
        C2["avisos"]
    end
    subgraph LOCAL["Local · useState"]
        L1["modal abierto"]
        L2["borrador del formulario"]
    end
    PANTALLA["Una pantalla"] --> SERVIDOR
    PANTALLA --> CLIENTE
    PANTALLA --> URL
    PANTALLA --> CONTEXTO
    PANTALLA --> LOCAL

  1. La capa de acceso a datos: src/api/cliente.js

Antes de escribir un solo hook, hay que decidir cómo se habla con la red. Repartir fetch por los componentes significa repetir la URL base, el Content-Type, la comprobación de respuesta.ok y el JSON.parse en quince sitios, y descubrir el día del despliegue que uno de ellos se olvidó de comprobar el estado.

// src/api/cliente.js
import { URL_API } from '../configuracion.js';

const TIEMPO_ESPERA = 10_000;   // 10 s: más allá, la red se da por perdida

/**
 * Error de la capa de datos. Lleva el código HTTP para que quien lo reciba
 * pueda decidir: 404 no es lo mismo que 500 ni que un fallo de red.
 */
export class ErrorApi extends Error {
  constructor(mensaje, { estado = null, url = null, cuerpo = null } = {}) {
    super(mensaje);
    this.name = 'ErrorApi';
    this.estado = estado;
    this.url = url;
    this.cuerpo = cuerpo;
  }

  get esNoEncontrado() {
    return this.estado === 404;
  }

  get esDeRed() {
    return this.estado === null;   // nunca hubo respuesta
  }

  get esDelServidor() {
    return this.estado !== null && this.estado >= 500;
  }
}

/**
 * Envoltorio único de fetch. Todas las peticiones del proyecto pasan por aquí.
 */
export async function peticion(ruta, opciones = {}) {
  const { metodo = 'GET', cuerpo, señal, ...resto } = opciones;

  // Tiempo de espera propio: fetch no lo trae, y una petición colgada
  // deja la interfaz en "cargando" para siempre
  const controlador = new AbortController();
  const temporizador = setTimeout(() => controlador.abort(), TIEMPO_ESPERA);

  // Si quien llama trae su propia señal (TanStack Query la pasa), se combinan
  const señalFinal = señal
    ? AbortSignal.any([señal, controlador.signal])
    : controlador.signal;

  const url = `${URL_API}${ruta}`;

  try {
    const respuesta = await fetch(url, {
      method: metodo,
      signal: señalFinal,
      headers: {
        Accept: 'application/json',
        ...(cuerpo ? { 'Content-Type': 'application/json' } : {}),
        ...resto.headers
      },
      ...(cuerpo ? { body: JSON.stringify(cuerpo) } : {}),
      ...resto
    });

    if (!respuesta.ok) {
      // Se intenta leer el cuerpo del error, pero su ausencia no debe romper nada
      let detalle = null;
      try {
        detalle = await respuesta.json();
      } catch {
        detalle = null;
      }

      throw new ErrorApi(mensajePorEstado(respuesta.status), {
        estado: respuesta.status,
        url,
        cuerpo: detalle
      });
    }

    // 204 No Content: no hay cuerpo que interpretar
    if (respuesta.status === 204) return null;

    return await respuesta.json();
  } catch (error) {
    if (error instanceof ErrorApi) throw error;

    if (error.name === 'AbortError') {
      throw new ErrorApi('La petición ha tardado demasiado.', { url });
    }

    // TypeError de fetch = no hubo respuesta: sin red, DNS, CORS…
    throw new ErrorApi('No se ha podido conectar con el servidor.', { url });
  } finally {
    clearTimeout(temporizador);
  }
}

function mensajePorEstado(estado) {
  if (estado === 404) return 'El recurso solicitado no existe.';
  if (estado === 401) return 'La sesión ha caducado.';
  if (estado === 403) return 'No tienes permiso para esta operación.';
  if (estado >= 500) return 'El servidor no está respondiendo correctamente.';
  return 'La petición no se ha podido completar.';
}

Lo que resuelve cada parte, porque cada una viene de un fallo real:

Parte Problema que evita
URL_API desde configuracion.js Cambiar de entorno sin tocar quince ficheros
ErrorApi con estado Poder distinguir 404 de 500 de fallo de red arriba, en la interfaz
AbortController con temporizador La petición colgada que deja el esqueleto girando indefinidamente
AbortSignal.any Combinar el tiempo de espera con la cancelación que envía TanStack Query al desmontar
Comprobación de respuesta.ok fetch no lanza con un 500: sin esto, un error del servidor llegaría como dato válido
try/catch al leer el cuerpo del error Una respuesta de error sin JSON no debe producir un segundo error más confuso que el primero
Caso 204 respuesta.json() sobre un cuerpo vacío lanza
mensajePorEstado Mensajes en español, comprensibles, en un solo sitio
finally con clearTimeout Un temporizador que sobrevive a la petición

  1. Funciones por recurso

Encima del envoltorio, una función por operación. Son las únicas que conocen las rutas de la API.

// src/api/bicicletas.js
import { peticion } from './cliente.js';

export function obtenerBicicletas({ tipo, señal } = {}) {
  // json-server filtra por campo con un parámetro de consulta
  const parametros = new URLSearchParams();
  if (tipo && tipo !== 'todos') parametros.set('tipo', tipo);

  const consulta = parametros.toString();
  return peticion(`/bicicletas${consulta ? `?${consulta}` : ''}`, { señal });
}

export function obtenerBicicleta(id, { señal } = {}) {
  return peticion(`/bicicletas/${id}`, { señal });
}

export function actualizarEstadoBicicleta(id, estado) {
  return peticion(`/bicicletas/${id}`, { metodo: 'PATCH', cuerpo: { estado } });
}
// src/api/reservas.js
import { peticion } from './cliente.js';

export function obtenerReservas({ usuarioId, señal } = {}) {
  const ruta = usuarioId ? `/reservas?usuario=${usuarioId}` : '/reservas';
  return peticion(ruta, { señal });
}

export function crearReserva(datos) {
  return peticion('/reservas', { metodo: 'POST', cuerpo: datos });
}

export function actualizarReserva(id, cambios) {
  return peticion(`/reservas/${id}`, { metodo: 'PATCH', cuerpo: cambios });
}
// src/api/estaciones.js
import { peticion } from './cliente.js';

export function obtenerEstaciones({ señal } = {}) {
  return peticion('/estaciones', { señal });
}

export function obtenerEstacion(id, { señal } = {}) {
  return peticion(`/estaciones/${id}`, { señal });
}
// src/api/usuarios.js
import { peticion } from './cliente.js';

export async function buscarUsuarioPorCorreo(email) {
  // json-server devuelve un array al filtrar; aquí se normaliza a un objeto o null
  const encontrados = await peticion(`/usuarios?email=${encodeURIComponent(email)}`);
  return encontrados[0] ?? null;
}

Ese encodeURIComponent no es paranoia: un correo con un + —perfectamente válido y bastante común— se interpretaría como un espacio en la cadena de consulta y la búsqueda no encontraría nada. Es un fallo que solo aparece con ciertos usuarios, que es la peor clase de fallo.

  1. Por qué esta capa no sabe nada de React

Ni un import de React, ni un hook, ni una referencia al estado. Es una decisión de arquitectura con cinco consecuencias medibles:

Beneficio En la práctica
Se prueba sin montar nada await obtenerBicicletas() con MSW interceptando, sin render ni proveedores
Se puede reutilizar fuera de React Un script de migración, una prueba de Cypress, una futura aplicación móvil (10-05)
Cambiar de biblioteca de datos no la afecta Si mañana se sustituye TanStack Query, esta capa no se toca
Un único punto de cambio La autenticación real de 11-05 se añade en peticion, y llega a todas las llamadas
Frontera clara en la revisión de código Un useState dentro de src/api/ es un error evidente, no una discusión de estilo

La regla que lo resume: src/api/ habla HTTP; src/consultas/ habla React. Si una función necesita saber si un componente está montado, está en la carpeta equivocada.

  1. clienteConsultas.js y sus valores por defecto

// src/consultas/clienteConsultas.js
import { QueryClient } from '@tanstack/react-query';
import { ErrorApi } from '../api/cliente.js';

export const clienteConsultas = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      refetchOnWindowFocus: true,
      retry: (intentos, error) => {
        // Un 404 no mejora reintentando: es una respuesta correcta a una pregunta mal hecha
        if (error instanceof ErrorApi && error.estado >= 400 && error.estado < 500) {
          return false;
        }
        return intentos < 2;
      }
    },
    mutations: {
      retry: false   // reintentar un POST puede crear dos reservas
    }
  }
});

Cada valor, con su justificación para este proyecto:

Opción Valor Por qué
staleTime 30 s El estado de las bicicletas cambia con el uso real de la red, pero no cada segundo. Medio minuto evita una tormenta de peticiones al navegar entre pantallas y mantiene el dato razonablemente fresco
gcTime 5 min Volver a una pantalla visitada hace poco es instantáneo, y la memoria no crece sin control
refetchOnWindowFocus true Alguien que deja la pestaña abierta veinte minutos y vuelve debe ver el estado actual, no el de antes de comer
retry de consultas Función Reintentar un 4xx es tiempo perdido: la respuesta no va a cambiar. Los 5xx y los fallos de red sí se reintentan dos veces
retry de mutaciones false Un POST /reservas reintentado tras un tiempo de espera agotado puede crear dos reservas. La API no es idempotente y no hay clave de idempotencia

La última fila es la más importante y la que más se ignora. Si la petición llegó al servidor y la respuesta se perdió por el camino, el reintento crea un duplicado. Con reservas, eso es dinero. Ante la duda, una mutación no se reintenta sola: se le ofrece a la persona el botón de volver a intentarlo.

Y un ajuste fino por recurso, allí donde el valor global no encaja:

// Las estaciones cambian de mes en mes, no de minuto en minuto
export function useEstaciones() {
  return useQuery({
    queryKey: claves.estaciones.todas(),
    queryFn: ({ signal }) => obtenerEstaciones({ señal: signal }),
    staleTime: 10 * 60_000        // 10 minutos: sobrescribe el global
  });
}

  1. La fábrica de claves

// src/consultas/claves.js
export const claves = {
  bicicletas: {
    todas: () => ['bicicletas'],
    lista: (filtros) => ['bicicletas', filtros],
    detalle: (id) => ['bicicletas', 'detalle', id]
  },
  estaciones: {
    todas: () => ['estaciones'],
    detalle: (id) => ['estaciones', id],
    incidencias: (id) => ['estaciones', id, 'incidencias']
  },
  reservas: {
    todas: () => ['reservas'],
    deUsuario: (usuarioId) => ['reservas', { usuario: usuarioId }]
  }
};

La jerarquía es la que hace que la invalidación sea precisa sin ser tediosa:

Invalidar Afecta a No afecta a
['bicicletas'] Todas las listas y todos los detalles Estaciones y reservas
['bicicletas', { tipo: 'urbana' }] Solo esa lista filtrada Las demás listas
['bicicletas', 'detalle', 'bici-001'] Solo esa ficha La lista
['reservas'] Las de todos los usuarios Bicicletas

TanStack Query compara las claves por prefijo, así que invalidar el padre alcanza a todos los hijos. Es exactamente el comportamiento que se quiere tras crear una reserva: cambia la lista, cambia la ficha de esa bicicleta y cambia la lista de reservas, y con dos líneas quedan las tres marcadas.

  1. Los hooks de consulta del proyecto

// src/consultas/bicicletas.js
import { useQuery } from '@tanstack/react-query';
import { obtenerBicicletas, obtenerBicicleta } from '../api/bicicletas.js';
import { claves } from './claves.js';

export function useBicicletas(filtros = {}) {
  return useQuery({
    queryKey: claves.bicicletas.lista(filtros),
    // signal lo aporta Query: cancela la petición si el componente se desmonta
    queryFn: ({ signal }) => obtenerBicicletas({ ...filtros, señal: signal })
  });
}

export function useBicicleta(id) {
  return useQuery({
    queryKey: claves.bicicletas.detalle(id),
    queryFn: ({ signal }) => obtenerBicicleta(id, { señal: signal }),
    enabled: Boolean(id)   // sin id no se lanza la petición
  });
}
// src/consultas/reservas.js
import { useQuery } from '@tanstack/react-query';
import { obtenerReservas } from '../api/reservas.js';
import { claves } from './claves.js';

export function useReservas(usuarioId) {
  return useQuery({
    queryKey: claves.reservas.deUsuario(usuarioId),
    queryFn: ({ signal }) => obtenerReservas({ usuarioId, señal: signal }),
    enabled: Boolean(usuarioId)   // sin sesión no hay reservas que pedir
  });
}

El enabled merece una nota. Sin él, al entrar en /reservas sin sesión se lanzaría GET /reservas?usuario=undefined, que en json-server devuelve una lista vacía y en una API real devolvería un 400. Con enabled: false, la consulta queda en estado pending sin pedir nada, y arranca sola en cuanto usuarioId deja de ser nulo. Es la forma correcta de expresar «esto depende de algo que todavía no tengo».

  1. Conectar el catálogo: de los interruptores a los estados reales

Aquí se cobra el trabajo de 11-02. Los interruptores CARGANDO y CON_ERROR desaparecen y su marcado se queda tal cual.

// src/paginas/PaginaCatalogo.jsx
import { useMemo, useDeferredValue } from 'react';
import { useSearchParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import { useBicicletas } from '../consultas/bicicletas.js';
import { useEstaciones } from '../consultas/estaciones.js';
import { seleccionarTermino, seleccionarOrden, terminoCambiado }
  from '../funcionalidades/catalogo/sliceCatalogo.js';
import Panel from '../componentes/base/Panel.jsx';
import SelectorTipo from '../componentes/SelectorTipo.jsx';
import BuscadorBicicletas from '../componentes/BuscadorBicicletas.jsx';
import ListaBicicletas from '../componentes/ListaBicicletas.jsx';
import ResumenFlota from '../componentes/ResumenFlota.jsx';
import EsqueletoPagina from '../componentes/EsqueletoPagina.jsx';
import Aviso from '../componentes/Aviso.jsx';
import Boton from '../componentes/base/Boton.jsx';
import estilos from './PaginaCatalogo.module.css';

function PaginaCatalogo() {
  // 1) El filtro vive en la URL
  const [parametros, setParametros] = useSearchParams();
  const tipo = parametros.get('tipo') ?? 'todos';

  // 2) El término y el orden viven en Redux
  const termino = useSelector(seleccionarTermino);
  const orden = useSelector(seleccionarOrden);
  const despachar = useDispatch();

  // 3) Los datos viven en el servidor
  const consulta = useBicicletas({ tipo });
  const { data: estaciones = [] } = useEstaciones();

  const terminoDiferido = useDeferredValue(termino);

  const visibles = useMemo(() => {
    const texto = terminoDiferido.trim().toLowerCase();
    const lista = (consulta.data ?? []).filter((bici) =>
      bici.modelo.toLowerCase().includes(texto)
    );

    return [...lista].sort((a, b) =>
      orden === 'precio' ? a.precioHora - b.precioHora : a.modelo.localeCompare(b.modelo)
    );
  }, [consulta.data, terminoDiferido, orden]);

  function manejarCambioTipo(nuevoTipo) {
    // replace: true evita llenar el historial con cada clic de filtro
    if (nuevoTipo === 'todos') {
      setParametros({}, { replace: true });
    } else {
      setParametros({ tipo: nuevoTipo }, { replace: true });
    }
  }

  if (consulta.isPending) return <EsqueletoPagina filas={5} />;

  if (consulta.isError) {
    return (
      <Aviso tono="error" titulo="No se han podido cargar las bicicletas">
        <p>{consulta.error.mensajeAmigable ?? consulta.error.message}</p>
        <Boton onClick={() => consulta.refetch()}>Reintentar</Boton>
      </Aviso>
    );
  }

  return (
    <>
      <h1 className={estilos.titulo}>Catálogo de bicicletas</h1>
      <p className={estilos.entradilla} aria-live="polite">
        {visibles.length} de {consulta.data.length} bicicletas
        {consulta.isFetching && <span className={estilos.actualizando}> · actualizando…</span>}
      </p>

      <ResumenFlota bicicletas={consulta.data} />

      <Panel titulo="Filtros" nivel={2} className={estilos.filtros}>
        <SelectorTipo tipoElegido={tipo} alCambiarTipo={manejarCambioTipo} />
        <BuscadorBicicletas
          termino={termino}
          alCambiarTermino={(valor) => despachar(terminoCambiado(valor))}
        />
      </Panel>

      {visibles.length === 0 ? (
        <div className={estilos.vacio}>
          <h2>Ninguna bicicleta coincide con la búsqueda</h2>
          <p>Prueba con otro tipo o borra el texto del buscador.</p>
        </div>
      ) : (
        <ListaBicicletas bicicletas={visibles} estaciones={estaciones} />
      )}
    </>
  );
}

export default PaginaCatalogo;

Las cuatro decisiones que definen esta pantalla:

  1. El filtro por tipo va al servidor; la búsqueda por texto, no. El tipo forma parte de la clave de consulta, así que cada tipo se cachea por separado y volver a «Eléctrica» es instantáneo. El texto, en cambio, cambia con cada tecla: mandarlo al servidor sería una petición por pulsación. Se filtra en cliente sobre datos ya cargados.
  2. isPending frente a isFetching. isPending es «no hay dato todavía» y pinta el esqueleto. isFetching es «hay dato y además se está refrescando», y se señala con un texto discreto: sustituir la lista por un esqueleto en cada revalidación sería un parpadeo constante e injustificado.
  3. replace: true al filtrar. Sin él, elegir cuatro filtros seguidos obliga a pulsar atrás cuatro veces para salir de la pantalla. El filtro debe ser enlazable, pero no cada paso intermedio tiene que ser una entrada del historial.
  4. aria-live="polite" en el recuento. Al filtrar, quien no ve la pantalla necesita enterarse de que el número de resultados ha cambiado. Es una línea y resuelve un problema real.

  1. La mutación completa: crear una reserva

La operación central del producto, con sus seis pasos.

// src/consultas/reservas.js — continuación
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { crearReserva } from '../api/reservas.js';
import { claves } from './claves.js';

export function useCrearReserva() {
  const cliente = useQueryClient();

  return useMutation({
    mutationFn: crearReserva,

    onSuccess: (reservaCreada) => {
      // La lista de reservas del usuario ha cambiado
      cliente.invalidateQueries({
        queryKey: claves.reservas.deUsuario(reservaCreada.usuario)
      });
      // La bicicleta pasa a estar comprometida: el catálogo entero puede haber cambiado
      cliente.invalidateQueries({ queryKey: claves.bicicletas.todas() });
    }
  });
}
// src/paginas/PaginaNuevaReserva.jsx
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useSelector } from 'react-redux';
import { useBicicletas } from '../consultas/bicicletas.js';
import { useCrearReserva } from '../consultas/reservas.js';
import { seleccionarUsuario } from '../funcionalidades/sesion/sliceSesion.js';
import { useAvisos } from '../contextos/avisos.js';
import { validarReserva } from '../utilidades/validarReserva.js';
import FormularioReserva from '../componentes/FormularioReserva.jsx';
import EsqueletoPagina from '../componentes/EsqueletoPagina.jsx';
import Aviso from '../componentes/Aviso.jsx';

function PaginaNuevaReserva() {
  const navegar = useNavigate();
  const [parametros] = useSearchParams();
  const usuario = useSelector(seleccionarUsuario);
  const { anadirAviso } = useAvisos();

  const consultaBicicletas = useBicicletas();
  const mutacion = useCrearReserva();

  const [errores, setErrores] = useState({});

  const valorInicial = {
    // Preselección desde la ficha: /reservas/nueva?bicicleta=bici-001
    bicicletaId: parametros.get('bicicleta') ?? '',
    fechaInicio: '',
    horas: 1,
    condiciones: false
  };

  function manejarEnviar(datos) {
    // 1) Validar contra los datos REALES, no contra una copia
    const erroresEncontrados = validarReserva(datos, consultaBicicletas.data ?? []);
    setErrores(erroresEncontrados);

    if (Object.keys(erroresEncontrados).length > 0) return;

    // 2) Enviar
    mutacion.mutate(
      {
        id: `res-${Date.now()}`,          // json-server acepta el id; una API real lo generaría
        bicicletaId: datos.bicicletaId,
        usuario: usuario.id,
        fechaInicio: datos.fechaInicio,
        horas: Number(datos.horas),
        estado: 'activa'
      },
      {
        // 3) Éxito: aviso y redirección
        onSuccess: () => {
          anadirAviso({ tono: 'exito', texto: 'Reserva creada correctamente.' });
          navegar('/reservas', { replace: true });
        },
        // 4) Error: aviso en línea, sin salir de la pantalla
        onError: (error) => {
          anadirAviso({
            tono: 'error',
            texto: `No se ha podido crear la reserva. ${error.message}`
          });
        }
      }
    );
  }

  if (consultaBicicletas.isPending) return <EsqueletoPagina filas={4} />;

  if (consultaBicicletas.isError) {
    return (
      <Aviso tono="error" titulo="No se ha podido cargar el catálogo">
        Sin la lista de bicicletas no se puede crear una reserva. Inténtalo de nuevo.
      </Aviso>
    );
  }

  return (
    <>
      <h1>Nueva reserva</h1>
      <FormularioReserva
        bicicletas={consultaBicicletas.data}
        valorInicial={valorInicial}
        errores={errores}
        enviando={mutacion.isPending}
        alEnviar={manejarEnviar}
      />
    </>
  );
}

export default PaginaNuevaReserva;

Los seis pasos y la razón de cada uno:

Paso Dónde Por qué así
1. Validar En la página, antes de mutate validarReserva necesita la lista real de bicicletas para comprobar que la elegida está disponible. Validar contra datos de hace cinco minutos permitiría reservar una bicicleta ya alquilada
2. Enviar mutacion.mutate isPending deshabilita el botón: la protección contra el doble envío no es un useState propio
3. Invalidar onSuccess del hook Va en el hook, no en la página: es una consecuencia del dominio, no de esta pantalla. Si otra pantalla crea reservas, la invalidación también ocurre
4. Avisar onSuccess de la llamada Va en la página: el aviso y la redirección son decisiones de esta pantalla concreta
5. Redirigir navegar('/reservas', { replace: true }) replace evita que el botón atrás devuelva al formulario ya enviado, con el riesgo de un segundo envío (06-04)
6. Manejar el error onError de la llamada El fallo de una mutación no debe sacar de la pantalla: los datos escritos siguen ahí y se puede reintentar

La distinción entre el onSuccess del hook y el de la llamada es sutil y muy útil: el del hook expresa lo que siempre es cierto (los datos afectados quedan obsoletos), el de la llamada expresa lo que quiere esta pantalla (avisar y navegar). Ambos se ejecutan, primero el del hook.

  1. Actualización optimista: confirmar y cancelar

Cancelar una reserva es una operación en la que esperar medio segundo a que responda el servidor se nota. La actualización optimista pinta el resultado antes de tenerlo, y lo deshace si falla.

// src/consultas/reservas.js — continuación
export function useCancelarReserva(usuarioId) {
  const cliente = useQueryClient();
  const clave = claves.reservas.deUsuario(usuarioId);

  return useMutation({
    mutationFn: (idReserva) => actualizarReserva(idReserva, { estado: 'cancelada' }),

    onMutate: async (idReserva) => {
      // 1) Detener las consultas en vuelo: si una llega después, pisaría el cambio
      await cliente.cancelQueries({ queryKey: clave });

      // 2) Guardar la foto actual para poder revertir
      const anterior = cliente.getQueryData(clave);

      // 3) Pintar el resultado ya
      cliente.setQueryData(clave, (reservas = []) =>
        reservas.map((reserva) =>
          reserva.id === idReserva ? { ...reserva, estado: 'cancelada' } : reserva
        )
      );

      // Lo devuelto llega a onError y onSettled como "contexto"
      return { anterior };
    },

    onError: (error, idReserva, contexto) => {
      // 4) Revertir al estado exacto de antes
      if (contexto?.anterior) {
        cliente.setQueryData(clave, contexto.anterior);
      }
    },

    onSettled: () => {
      // 5) Pase lo que pase, sincronizar con el servidor
      cliente.invalidateQueries({ queryKey: clave });
      cliente.invalidateQueries({ queryKey: claves.bicicletas.todas() });
    }
  });
}
sequenceDiagram
    participant U as Usuario
    participant C as Componente
    participant Q as Caché de Query
    participant S as Servidor

    U->>C: Pulsa "Sí, cancelar"
    C->>Q: mutate(idReserva)
    Q->>Q: onMutate: cancelQueries + foto + setQueryData
    Q-->>C: La fila ya muestra "Cancelada"
    Q->>S: PATCH /reservas/res-01
    alt Respuesta correcta
        S-->>Q: 200 OK
        Q->>Q: onSettled: invalidateQueries
        Q->>S: GET /reservas (confirmación)
    else Error
        S-->>Q: 500
        Q->>Q: onError: setQueryData(foto)
        Q-->>C: La fila vuelve a "Activa"
        C-->>U: Aviso "No se ha podido cancelar"
    end

Los tres pasos que no se pueden saltar, con la consecuencia exacta de omitir cada uno:

Paso Si se omite
cancelQueries Una consulta lanzada antes de la mutación llega después y restaura el estado antiguo. El fallo intermitente perfecto: pasa una vez de cada diez
Guardar anterior No hay a qué revertir. La interfaz se queda mintiendo hasta la siguiente recarga
onSettled con invalidateQueries La caché conserva el valor adivinado, no el real. Si el servidor guardó algo distinto —una marca de tiempo, un estado derivado—, nunca te enteras

Y la pregunta de fondo: ¿cuándo merece la pena?

Operación ¿Optimista? Motivo
Cancelar una reserva Cambio de un campo, resultado predecible, fallo raro y reversible
Confirmar una reserva Ídem
Cambiar el estado de una bicicleta (taller) Ídem, y el operario hace muchas seguidas
Crear una reserva No El servidor asigna el identificador; adivinarlo obliga a reconciliar después. Y si falla, hay que retirar de la lista algo que la persona ya vio creado
Cualquier operación con pago No Nunca se muestra como hecho algo que implica dinero y no está confirmado

  1. Redux: la sesión

// src/funcionalidades/sesion/sliceSesion.js
import { createSlice } from '@reduxjs/toolkit';

const CLAVE_ALMACEN = 'ciclourbano:sesion';

function leerSesionGuardada() {
  try {
    const guardado = localStorage.getItem(CLAVE_ALMACEN);
    return guardado ? JSON.parse(guardado) : null;
  } catch {
    // JSON corrupto o almacenamiento bloqueado: se empieza sin sesión
    return null;
  }
}

const estadoInicial = {
  usuario: leerSesionGuardada(),
  cargando: false,
  error: null
};

const sliceSesion = createSlice({
  name: 'sesion',
  initialState: estadoInicial,
  reducers: {
    accesoIniciado(estado) {
      estado.cargando = true;
      estado.error = null;
    },
    sesionIniciada(estado, accion) {
      estado.usuario = accion.payload;
      estado.cargando = false;
      estado.error = null;
    },
    accesoFallido(estado, accion) {
      estado.usuario = null;
      estado.cargando = false;
      estado.error = accion.payload;
    },
    sesionCerrada(estado) {
      estado.usuario = null;
      estado.error = null;
    }
  }
});

export const { accesoIniciado, sesionIniciada, accesoFallido, sesionCerrada } =
  sliceSesion.actions;

// Selectores: el slice es el único que conoce la forma del estado
export const seleccionarUsuario = (estado) => estado.sesion.usuario;
export const seleccionarEstaIdentificado = (estado) => estado.sesion.usuario !== null;
export const seleccionarEsOperario = (estado) => estado.sesion.usuario?.rol === 'operario';
export const seleccionarCargandoSesion = (estado) => estado.sesion.cargando;
export const seleccionarErrorSesion = (estado) => estado.sesion.error;

export default sliceSesion.reducer;

La persistencia se resuelve con un middleware, no repitiendo localStorage.setItem en cada reductor:

// src/almacen/persistenciaSesion.js
const CLAVE_ALMACEN = 'ciclourbano:sesion';

export const persistenciaSesion = (almacen) => (siguiente) => (accion) => {
  const resultado = siguiente(accion);

  // Solo reacciona a las acciones de sesión: no escribe en cada tecla del buscador
  if (accion.type.startsWith('sesion/')) {
    const usuario = almacen.getState().sesion.usuario;
    try {
      if (usuario) {
        localStorage.setItem(CLAVE_ALMACEN, JSON.stringify(usuario));
      } else {
        localStorage.removeItem(CLAVE_ALMACEN);
      }
    } catch {
      // Modo privado o cuota llena: la aplicación sigue funcionando sin persistencia
    }
  }

  return resultado;
};
// src/almacen/almacen.js
import { configureStore } from '@reduxjs/toolkit';
import reductorSesion from '../funcionalidades/sesion/sliceSesion.js';
import reductorCatalogo from '../funcionalidades/catalogo/sliceCatalogo.js';
import reductorReservas from '../funcionalidades/reservas/sliceReservas.js';
import { persistenciaSesion } from './persistenciaSesion.js';

export const almacen = configureStore({
  reducer: {
    sesion: reductorSesion,
    catalogo: reductorCatalogo,
    reservas: reductorReservas
  },
  middleware: (obtenerPorDefecto) => obtenerPorDefecto().concat(persistenciaSesion)
});

Por qué un middleware y no useEffect en un componente, ni useAlmacenLocal:

Enfoque Problema
useEffect que observa el usuario Solo funciona si el componente está montado. Un cierre de sesión desde un lugar sin ese componente no persiste
useAlmacenLocal en el componente de acceso Dos fuentes de verdad: el hook y Redux. Se desincronizan en cuanto alguien despacha sesionCerrada desde otro sitio
Middleware Ve todas las acciones, esté montado lo que esté montado. Un único punto, imposible de saltarse

Y la página de acceso, que junta todo:

// src/paginas/PaginaAcceso.jsx (extracto de la lógica)
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useLocation } from 'react-router';
import { buscarUsuarioPorCorreo } from '../api/usuarios.js';
import { accesoIniciado, sesionIniciada, accesoFallido, seleccionarCargandoSesion, seleccionarErrorSesion }
  from '../funcionalidades/sesion/sliceSesion.js';

function PaginaAcceso() {
  const [correo, setCorreo] = useState('');
  const despachar = useDispatch();
  const navegar = useNavigate();
  const location = useLocation();
  const cargando = useSelector(seleccionarCargandoSesion);
  const error = useSelector(seleccionarErrorSesion);

  // A dónde volver: lo dejó RutaProtegida al expulsar
  const destino = location.state?.desde?.pathname ?? '/';

  async function manejarEnviar(evento) {
    evento.preventDefault();
    despachar(accesoIniciado());

    try {
      const usuario = await buscarUsuarioPorCorreo(correo.trim());

      if (!usuario) {
        despachar(accesoFallido('No hay ninguna cuenta con ese correo.'));
        return;
      }

      despachar(sesionIniciada(usuario));
      navegar(destino, { replace: true });
    } catch (fallo) {
      despachar(accesoFallido(fallo.message));
    }
  }
  // …el marcado es el de 11-02, con cargando y error conectados
}

Y el aviso que hay que decir en voz alta: esto no es autenticación. Es una búsqueda por correo sin contraseña, sin token y sin comprobación de nada, aceptable en un proyecto de aprendizaje con json-server y absolutamente insuficiente para producción. En 11-05 se detalla qué haría falta de verdad.

  1. Redux: el catálogo, y qué no entra en Redux

// src/funcionalidades/catalogo/sliceCatalogo.js
import { createSlice } from '@reduxjs/toolkit';

const sliceCatalogo = createSlice({
  name: 'catalogo',
  initialState: { termino: '', orden: 'modelo' },
  reducers: {
    terminoCambiado(estado, accion) {
      estado.termino = accion.payload;
    },
    ordenCambiado(estado, accion) {
      estado.orden = accion.payload;
    },
    filtrosReiniciados(estado) {
      estado.termino = '';
      estado.orden = 'modelo';
    }
  }
});

export const { terminoCambiado, ordenCambiado, filtrosReiniciados } = sliceCatalogo.actions;

export const seleccionarTermino = (estado) => estado.catalogo.termino;
export const seleccionarOrden = (estado) => estado.catalogo.orden;

export default sliceCatalogo.reducer;

Fíjate en lo que no está: tipo no aparece por ningún lado, porque vive en la URL. Tenerlo en los dos sitios sería garantizar que un día se desincronizan.

La lista de lo que se decidió dejar fuera de Redux, con su motivo:

Dato Por qué no está en Redux
Bicicletas, estaciones, reservas Estado del servidor: es de TanStack Query (A3)
Filtro por tipo Es de la URL: debe ser compartible (A6)
Tema Contexto: dos valores y ningún consumidor exigente
Avisos Contexto: los emite cualquiera y los pinta el marco
Modal abierto, borrador del formulario Local: nadie más los necesita
isPending de las mutaciones Ya lo da Query; duplicarlo es garantizar la desincronización

La regla que resume las seis filas: al almacén global solo sube lo que dos componentes lejanos necesitan compartir y no encaja mejor en otra herramienta. Redux no es el sitio donde va todo; es el sitio donde va lo que le corresponde.

  1. Contexto: tema y avisos

ProveedorTema ya quedó escrito en 11-02. Los avisos siguen el patrón de contextos divididos de 07-02, porque es el que evita la mayoría de los repintados inútiles:

// src/contextos/avisos.jsx
import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';

const ContextoEstadoAvisos = createContext(null);
const ContextoAccionesAvisos = createContext(null);

export function ProveedorAvisos({ children }) {
  const [avisos, setAvisos] = useState([]);
  const siguienteId = useRef(1);

  const anadirAviso = useCallback(({ tono = 'info', texto, duracion = 5000 }) => {
    const id = siguienteId.current++;
    setAvisos((actuales) => [...actuales, { id, tono, texto }]);

    if (duracion > 0) {
      setTimeout(() => {
        setAvisos((actuales) => actuales.filter((aviso) => aviso.id !== id));
      }, duracion);
    }

    return id;
  }, []);

  const quitarAviso = useCallback((id) => {
    setAvisos((actuales) => actuales.filter((aviso) => aviso.id !== id));
  }, []);

  // Las acciones NUNCA cambian de identidad: los emisores no se repintan jamás
  const acciones = useMemo(() => ({ anadirAviso, quitarAviso }), [anadirAviso, quitarAviso]);

  return (
    <ContextoAccionesAvisos.Provider value={acciones}>
      <ContextoEstadoAvisos.Provider value={avisos}>
        {children}
      </ContextoEstadoAvisos.Provider>
    </ContextoAccionesAvisos.Provider>
  );
}

export function useAvisos() {
  const contexto = useContext(ContextoAccionesAvisos);
  if (!contexto) throw new Error('useAvisos debe usarse dentro de ProveedorAvisos.');
  return contexto;
}

export function useListaAvisos() {
  const contexto = useContext(ContextoEstadoAvisos);
  if (contexto === null) throw new Error('useListaAvisos debe usarse dentro de ProveedorAvisos.');
  return contexto;
}

El motivo de partirlo en dos es concreto y medible. PaginaNuevaReserva solo necesita emitir avisos; ListaAvisos solo necesita leerlos. Con un único contexto, cada aviso que aparece y desaparece repintaría el formulario entero. Con dos, el formulario consume un valor que nunca cambia de identidad —gracias a useCallback con dependencias vacías— y no se repinta nunca por culpa de los avisos.

// src/componentes/ListaAvisos.jsx
import { memo } from 'react';
import { useListaAvisos, useAvisos } from '../contextos/avisos.jsx';
import estilos from './ListaAvisos.module.css';

function ListaAvisos() {
  const avisos = useListaAvisos();
  const { quitarAviso } = useAvisos();

  if (avisos.length === 0) return null;

  return (
    <div className={estilos.lista}>
      {avisos.map((aviso) => (
        <div
          key={aviso.id}
          className={`${estilos.aviso} ${estilos[aviso.tono]}`}
          data-testid="aviso"
          /* Los errores interrumpen; el resto espera su turno */
          role={aviso.tono === 'error' ? 'alert' : 'status'}
        >
          <p>{aviso.texto}</p>
          <button type="button" onClick={() => quitarAviso(aviso.id)} aria-label="Cerrar aviso">
            ×
          </button>
        </div>
      ))}
    </div>
  );
}

export default memo(ListaAvisos);

La elección de role no es cosmética: alert interrumpe la lectura en curso de un lector de pantalla y status espera a que termine. Un error merece la interrupción; un «Reserva creada» no.

  1. El filtro en la URL con useSearchParams

Ya está usado en el catálogo; conviene entender la cadena completa, porque es lo que hace que una URL compartida funcione:

flowchart LR
    A["URL: /?tipo=electrica"] --> B["useSearchParams<br/>tipo = 'electrica'"]
    B --> C["claves.bicicletas.lista({tipo:'electrica'})"]
    C --> D["¿Está en caché y fresca?"]
    D -- "Sí" --> E["Datos al instante"]
    D -- "No" --> F["GET /bicicletas?tipo=electrica"]
    F --> E
    E --> G["ListaBicicletas"]
    H["Clic en 'Urbana'"] --> I["setParametros({tipo:'urbana'}, {replace:true})"]
    I --> A

Lo que se gana, y que ninguna otra ubicación del estado da:

  • Enlace compartible: pegar /?tipo=electrica en un chat lleva a quien lo abra exactamente a esa vista.
  • Recarga fiel: F5 no pierde el filtro.
  • Botón atrás coherente: al entrar en una ficha y volver, el filtro sigue aplicado.
  • Clave de caché gratis: cada filtro tiene su entrada en la caché de Query, así que alternar entre tipos ya vistos es instantáneo.

Y el detalle que hay que cuidar: la URL es una entrada del usuario, y puede traer basura. ?tipo=cohete no debe romper nada:

const TIPOS_VALIDOS = ['todos', 'urbana', 'electrica', 'carga'];

const tipoBruto = parametros.get('tipo') ?? 'todos';
const tipo = TIPOS_VALIDOS.includes(tipoBruto) ? tipoBruto : 'todos';

Sin ese saneamiento, un valor inventado produciría una clave de consulta nueva, una petición inútil y una lista vacía sin explicación.

  1. Rutas protegidas conectadas a la sesión real

// src/componentes/RutaProtegida.jsx
import { Navigate, Outlet, useLocation } from 'react-router';
import { useSelector } from 'react-redux';
import { seleccionarUsuario, seleccionarCargandoSesion }
  from '../funcionalidades/sesion/sliceSesion.js';
import EsqueletoPagina from './EsqueletoPagina.jsx';

function RutaProtegida() {
  const usuario = useSelector(seleccionarUsuario);
  const cargando = useSelector(seleccionarCargandoSesion);
  const location = useLocation();

  // Mientras no se sabe si hay sesión, NO se decide nada
  if (cargando) return <EsqueletoPagina filas={3} />;

  if (!usuario) {
    // state.desde: PaginaAcceso lo usa para volver aquí tras identificarse
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  return <Outlet />;
}

export default RutaProtegida;
// src/componentes/RequiereRol.jsx
import { Navigate, Outlet } from 'react-router';
import { useSelector } from 'react-redux';
import { seleccionarUsuario } from '../funcionalidades/sesion/sliceSesion.js';

function RequiereRol({ rol }) {
  const usuario = useSelector(seleccionarUsuario);

  // Sin permiso NO se redirige a /acceso: la persona ya está identificada.
  // Mandarla al acceso sugeriría que el problema es de sesión, y no lo es.
  if (usuario?.rol !== rol) {
    return <Navigate to="/sin-permisos" replace />;
  }

  return <Outlet />;
}

export default RequiereRol;

El estado cargando parece innecesario con la sesión leída de forma síncrona de localStorage, y lo es hoy; se conserva porque el día que la sesión se valide contra el servidor —lo normal en producción— habrá un instante en el que no se sabe si hay sesión, y sin esta guarda ese instante expulsaría a /acceso a alguien que sí estaba identificado. Es un parpadeo clásico y muy molesto.

Y la advertencia que hay que repetir cada vez que aparece este código: esto es control de acceso de interfaz, no de seguridad. Impide que alguien navegue por error a una pantalla que no le corresponde, y nada más. Cualquiera puede abrir las herramientas de desarrollo, modificar el estado de Redux y ver /taller; y si el botón «Enviar a mantenimiento» dispara un PATCH que el servidor acepta sin comprobar nada, el daño es real. La autorización se comprueba en el servidor, en cada petición, siempre. Lo del cliente es comodidad.

  1. Manejo de errores de punta a punta

Con la red conectada aparecen errores de verdad. La pregunta que hay que responder para cada uno es quién lo muestra, y esta tabla es la respuesta del proyecto:

Situación Quién lo muestra Qué ve la persona Se recupera con
Fallo de red al cargar el catálogo La propia pantalla (isError) Aviso con mensaje y botón «Reintentar» refetch()
500 del servidor al cargar Ídem Ídem refetch(), tras dos reintentos automáticos
404 de una bicicleta inexistente La pantalla de la ficha «Esta bicicleta no existe» + enlace al catálogo Navegando
URL inexistente (/inventada) Ruta * PaginaNoEncontrada Navegando
Rol insuficiente RequiereRol PaginaSinPermisos Cambiando de sesión
Sesión caducada (401) Middleware de peticion → cierre de sesión Redirección a /acceso con aviso Identificándose
Error al mutar (crear reserva) Aviso en línea, sin salir «No se ha podido crear la reserva» Volviendo a enviar
Excepción al renderizar una ruta errorElement PaginaErrorRuta, con cabecera y pie intactos Navegando o recargando
Excepción fuera del enrutador LimiteDeError de main.jsx Pantalla de fallo general Recargando
Fragmento lazy que no carga errorElement de la ruta Ídem, con invitación a recargar Recargando
flowchart TD
    A["Ha ocurrido un error"] --> B{"¿Es de datos<br/>o de renderizado?"}
    B -- "Renderizado" --> C{"¿Dentro de una ruta?"}
    C -- "Sí" --> D["errorElement<br/>PaginaErrorRuta"]
    C -- "No" --> E["LimiteDeError<br/>pantalla general"]
    B -- "Datos" --> F{"¿Consulta o mutación?"}
    F -- "Consulta" --> G{"¿Qué código?"}
    G -- "404" --> H["Mensaje propio de la pantalla"]
    G -- "401" --> I["Cerrar sesión y redirigir a /acceso"]
    G -- "otros" --> J["Aviso con Reintentar (refetch)"]
    F -- "Mutación" --> K["Aviso en línea<br/>sin perder los datos escritos"]

El principio que ordena todo el cuadro: cuanto más localizado sea el error, más localizada debe ser su respuesta. Que falle una consulta del catálogo no justifica derribar la aplicación entera; que falle el renderizado de un componente sí justifica sustituir esa rama. Y en ningún caso se muestra una pantalla en blanco.

La sesión caducada se resuelve en la capa de datos, para que ninguna pantalla tenga que acordarse:

// src/api/cliente.js — añadido al manejo de errores
import { almacen } from '../almacen/almacen.js';
import { sesionCerrada } from '../funcionalidades/sesion/sliceSesion.js';

if (respuesta.status === 401) {
  almacen.dispatch(sesionCerrada());
  // El enrutador reaccionará: RutaProtegida verá usuario === null y redirigirá
}

Es la única concesión de src/api/ a algo que no es HTTP, y se acepta porque la alternativa —repetir la comprobación del 401 en cada hook— es peor. Aun así, se importa el almacén, no React: la capa sigue siendo utilizable fuera de un componente.

  1. main.jsx definitivo

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RouterProvider } from 'react-router';

import { almacen } from './almacen/almacen.js';
import { clienteConsultas } from './consultas/clienteConsultas.js';
import { router } from './rutas.jsx';
import LimiteDeError from './componentes/LimiteDeError.jsx';
import { Proveedores } from './contextos/Proveedores.jsx';
import { registrarError } from './utilidades/monitorizacion.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LimiteDeError titulo="CicloUrbano no está disponible ahora mismo" alRegistrar={registrarError}>
      <QueryClientProvider client={clienteConsultas}>
        <Provider store={almacen}>
          <Proveedores>
            <RouterProvider router={router} />
          </Proveedores>
        </Provider>
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </LimiteDeError>
  </StrictMode>
);
// src/contextos/Proveedores.jsx
import { ProveedorTema } from './ProveedorTema.jsx';
import { ProveedorAvisos } from './avisos.jsx';

export function Proveedores({ children }) {
  return (
    <ProveedorTema>
      <ProveedorAvisos>{children}</ProveedorAvisos>
    </ProveedorTema>
  );
}

El orden no es arbitrario, y cada nivel tiene su razón:

Nivel Por qué está ahí
StrictMode Detecta efectos no idempotentes en desarrollo (04-03). El más externo
LimiteDeError Debe poder capturar un fallo de cualquier proveedor, así que va por fuera de todos
QueryClientProvider Fuera de Redux: un middleware podría querer invalidar consultas, y no al revés
Provider (Redux) Fuera del enrutador: RutaProtegida y RequiereRol leen la sesión
Proveedores Tema y avisos los necesita todo el árbol de rutas
RouterProvider El más interno. Todo lo anterior debe estar disponible dentro de las rutas

La regla general, y vale para cualquier proyecto: un proveedor va por fuera de todo lo que lo consume. RouterProvider es siempre el último porque las rutas consumen todo lo demás.

  1. El flujo completo de una reserva

Todo lo de esta lección, en un único recorrido:

sequenceDiagram
    participant U as Ana (usuaria)
    participant P as PaginaNuevaReserva
    participant V as validarReserva
    participant M as useCrearReserva
    participant A as src/api/reservas.js
    participant S as json-server
    participant Q as Caché de Query
    participant C as PaginaCatalogo

    U->>P: Rellena el formulario y envía
    P->>V: validarReserva(datos, bicicletas de la caché)
    alt Hay errores
        V-->>P: { horas: 'La reserva máxima es de 24 horas.' }
        P-->>U: Mensajes junto a cada campo (role="alert")
    else Válido
        V-->>P: {}
        P->>M: mutate(reserva)
        M-->>P: isPending: el botón se deshabilita
        M->>A: crearReserva(datos)
        A->>S: POST /reservas
        S-->>A: 201 + reserva creada
        A-->>M: objeto reserva
        M->>Q: invalidateQueries(reservas del usuario)
        M->>Q: invalidateQueries(bicicletas)
        M-->>P: onSuccess
        P->>U: Aviso "Reserva creada correctamente"
        P->>U: navegar('/reservas', replace)
        Q->>S: GET /reservas?usuario=usr-01 (refresco automático)
        S-->>Q: lista actualizada
        Q-->>C: Al volver al catálogo, datos ya frescos
    end

Fíjate en lo que no aparece en el diagrama y sin embargo ocurre: nadie escribe «recargar la lista de reservas». La invalidación marca los datos como obsoletos y Query decide cuándo pedirlos: al instante si hay un componente montado observando esa clave, o en la siguiente ocasión si no lo hay. Ese es el trabajo que 07-06 argumentó que no merecía la pena reimplementar, y aquí se ve por qué.

  1. Comprobación manual del recorrido

Antes de escribir la primera prueba automática (11-04), el recorrido completo a mano, con la pestaña de red abierta:

# Acción Qué debe ocurrir
1 Arrancar con npm run dev:todo y abrir / Esqueleto, después la lista. Una sola petición GET /bicicletas
2 Ir a /estaciones y volver a / La segunda vez no hay petición: staleTime de 30 s
3 Esperar 40 s y volver Ahora sí refresca, y la lista no parpadea (isFetching, no isPending)
4 Pulsar «Eléctrica» URL con ?tipo=electrica, petición nueva, dos resultados
5 Recargar la página El filtro sigue aplicado
6 Copiar la URL en otra pestaña Misma vista filtrada
7 Escribir «carga» en el buscador Ninguna petición: se filtra en cliente
8 Ir a /reservas sin sesión Redirección a /acceso
9 Entrar con [email protected] Vuelve a /reservas, no al inicio
10 Recargar La sesión se mantiene
11 Crear una reserva válida Aviso de éxito, redirección, la reserva aparece en la lista
12 Volver al catálogo El estado de la bicicleta se ha actualizado
13 Cancelar una reserva La fila cambia al instante; después llega la confirmación
14 Parar json-server y cancelar otra La fila cambia y vuelve atrás, con aviso de error
15 Con la API parada, recargar / Aviso de error con «Reintentar»
16 Arrancar la API y pulsar «Reintentar» La lista aparece
17 Abrir /bicicletas/no-existe Mensaje propio de bicicleta inexistente
18 Con Ana, abrir /taller PaginaSinPermisos
19 Entrar con [email protected] y abrir /taller Se ve, con mantenimiento primero
20 Cerrar sesión Vuelta al catálogo, /reservas protegida otra vez

Los pasos 13 y 14 son los que hay que hacer con calma: la actualización optimista es la parte que más falla en silencio, y ver la reversión con los propios ojos es la única forma de saber que está bien cableada.

Errores Comunes y Consejos

  • Guardar los datos del servidor en useState con useEffect. Es el patrón que TanStack Query existe para eliminar: sin caché, sin deduplicación, con condiciones de carrera y con el estado de error a medio hacer. Si aparece un useEffect que hace fetch, algo se ha torcido.
  • Duplicar isPending en un useState propio. Produce el botón que se queda cargando para siempre porque la rama de error olvidó ponerlo a false. El estado de la mutación ya existe: úsalo.
  • Olvidar cancelQueries en onMutate. Es el fallo intermitente perfecto: una consulta en vuelo aterriza después de la actualización optimista y restaura el valor antiguo. Falla una vez de cada diez y se diagnostica fatal.
  • No devolver el contexto de onMutate. Sin la foto anterior no hay reversión posible, y la interfaz se queda mostrando un cambio que el servidor rechazó.
  • Reintentar mutaciones automáticamente. Un POST reintentado tras un tiempo de espera agotado puede crear dos reservas. retry: false en mutaciones y botón de reintento para la persona.
  • Reintentar un 404. Tres peticiones y tres segundos para llegar a la misma respuesta. La función de retry debe distinguir 4xx de 5xx.
  • Poner el mismo dato en dos sitios. El filtro por tipo en la URL y en Redux es la receta garantizada de la desincronización. Un dato, un dueño.
  • Confiar en RutaProtegida como medida de seguridad. Es comodidad de interfaz. La autorización se comprueba en el servidor en cada petición, sin excepción.
  • Invalidar ['bicicletas'] desde la página en lugar del hook. Si la invalidación es una consecuencia del dominio, va en el hook de mutación: así ocurre desde cualquier pantalla que use ese hook, hoy y dentro de un año.
  • No sanear los parámetros de la URL. ?tipo=cohete produce una clave de caché nueva, una petición inútil y una lista vacía sin explicación.
  • Consejo: ten abiertas las DevTools de TanStack Query mientras desarrollas. Ver las claves, su estado (fresca, obsoleta, inactiva) y sus refrescos convierte en obvio lo que de otro modo son horas de conjeturas.
  • Consejo: prueba siempre con la API parada. Es la forma más rápida de comprobar que los estados de error existen de verdad y que se puede salir de ellos.

Ejercicios

Ejercicio 1. Implementa la funcionalidad del taller (H7): el hook useCambiarEstadoBicicleta con actualización optimista y su conexión en PaginaTaller. Debe actualizar tanto la lista de bicicletas como la ficha individual si está en caché, revertir ante error, y avisar del resultado. Indica qué claves invalidas y por qué, y qué ocurre si el operario pulsa dos botones seguidos muy rápido.

Ejercicio 2. Un compañero informa de este fallo: «si entro en el catálogo, filtro por eléctrica, entro en una ficha y vuelvo atrás, a veces veo la lista sin filtrar durante un instante». Diagnostica las dos causas posibles, explica cómo distinguir cuál es, y corrige la que corresponda al proyecto tal y como está escrito en esta lección.

Ejercicio 3. La API real de producción va a devolver 401 cuando el token caduque, y el equipo quiere que la persona no pierda lo que estaba haciendo: en lugar de expulsarla al instante, debe verse un aviso con un botón para volver a identificarse que, al hacerlo, la devuelva a la pantalla donde estaba. Diseña la solución indicando qué capa se encarga de qué, escribe el código de las partes nuevas, y explica qué problema tiene la solución actual del apartado 16.

Soluciones

Solución 1.

// src/consultas/bicicletas.js — continuación
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { actualizarEstadoBicicleta } from '../api/bicicletas.js';

export function useCambiarEstadoBicicleta() {
  const cliente = useQueryClient();

  return useMutation({
    mutationFn: ({ id, estado }) => actualizarEstadoBicicleta(id, estado),

    onMutate: async ({ id, estado }) => {
      // 1) Detener TODAS las consultas de bicicletas, listas y detalles
      await cliente.cancelQueries({ queryKey: claves.bicicletas.todas() });

      // 2) Foto de todas las entradas afectadas: hay varias listas (una por tipo)
      const listasAnteriores = cliente.getQueriesData({ queryKey: claves.bicicletas.todas() });

      // 3) Actualizar cada lista en caché
      cliente.setQueriesData({ queryKey: claves.bicicletas.todas() }, (datos) => {
        if (!Array.isArray(datos)) return datos;   // el detalle no es un array
        return datos.map((bici) => (bici.id === id ? { ...bici, estado } : bici));
      });

      // 4) Y la ficha individual, si está en caché
      cliente.setQueryData(claves.bicicletas.detalle(id), (bici) =>
        bici ? { ...bici, estado } : bici
      );

      return { listasAnteriores, id };
    },

    onError: (error, variables, contexto) => {
      // Reversión completa: se restaura cada entrada con su clave original
      contexto?.listasAnteriores?.forEach(([clave, datos]) => {
        cliente.setQueryData(clave, datos);
      });
    },

    onSettled: (datos, error, variables) => {
      cliente.invalidateQueries({ queryKey: claves.bicicletas.todas() });
      cliente.invalidateQueries({ queryKey: claves.bicicletas.detalle(variables.id) });
    }
  });
}
// src/paginas/PaginaTaller.jsx (extracto)
function PaginaTaller() {
  const consulta = useBicicletas();
  const mutacion = useCambiarEstadoBicicleta();
  const { anadirAviso } = useAvisos();

  function manejarCambio(bicicleta, nuevoEstado) {
    mutacion.mutate(
      { id: bicicleta.id, estado: nuevoEstado },
      {
        onSuccess: () =>
          anadirAviso({
            tono: 'exito',
            texto: `${bicicleta.modelo} ahora está ${nuevoEstado === 'mantenimiento' ? 'en mantenimiento' : 'disponible'}.`
          }),
        onError: (error) =>
          anadirAviso({ tono: 'error', texto: `No se ha podido actualizar. ${error.message}` })
      }
    );
  }

  if (consulta.isPending) return <EsqueletoPagina filas={4} />;
  // …resto del marcado de 11-02, con onClick={() => manejarCambio(bici, 'mantenimiento')}
}

Qué claves se invalidan y por qué:

Clave Motivo
['bicicletas'] Alcanza por prefijo a todas las listas filtradas: {tipo:'todos'}, {tipo:'urbana'}… Cualquiera de ellas puede contener esa bicicleta
['bicicletas','detalle',id] La ficha individual es una entrada distinta y no cuelga de una lista
['reservas'] No se invalida: cambiar el estado de una bicicleta no altera ninguna reserva existente. Invalidarla sería una petición inútil

Uso de setQueriesData en plural, con la comprobación Array.isArray: la ficha individual comparte el prefijo ['bicicletas'] y no es un array, así que sin esa guarda el map reventaría. Es el precio de las claves jerárquicas y hay que tenerlo presente.

Si el operario pulsa dos botones seguidos muy rápido: se disparan dos mutaciones en paralelo, y ahí hay un riesgo real. La segunda ejecuta su onMutate después de que la primera ya haya modificado la caché, así que su foto listasAnteriores incluye el cambio de la primera. Si la segunda falla y la primera fue bien, la reversión de la segunda restaura un estado correcto —el que incluía el cambio uno—; hasta ahí, bien. El problema real aparece si falla la primera: su reversión restaura una foto anterior a las dos, borrando visualmente el cambio de la segunda, que sí tuvo éxito. El onSettled con invalidateQueries corrige la discrepancia en cuanto llega la respuesta del servidor, pero durante un instante la interfaz miente.

Tres formas de resolverlo, de menos a más:

  1. Deshabilitar el botón de esa bicicleta mientras su mutación está en vuelo (mutacion.isPending && mutacion.variables?.id === bici.id). Simple, suficiente y honesto con el usuario.
  2. Usar useMutationState para llevar el registro de las mutaciones pendientes y aplicarlas todas al recalcular la caché.
  3. Serializar con scope, la opción de TanStack Query v5 que ejecuta las mutaciones de un mismo ámbito una detrás de otra:
return useMutation({
  scope: { id: 'estado-bicicletas' },   // se encolan, no se solapan
  mutationFn: ({ id, estado }) => actualizarEstadoBicicleta(id, estado),
  // …
});

Para el taller, la 1 y la 3 juntas son la respuesta proporcionada.

Solución 2.

Las dos causas posibles, y son muy distintas:

Causa A — el filtro no está en la URL, o no se lee al montar. Si PaginaCatalogo guardara el tipo en useState inicializado a 'todos' y solo lo sincronizara con la URL en un useEffect, al volver atrás se renderizaría primero con 'todos' —lista completa— y después con el valor correcto. El parpadeo sería siempre, no «a veces».

Causa B — la caché de la lista sin filtrar se muestra mientras llega la filtrada. Si al volver atrás la clave ['bicicletas', {tipo:'electrica'}] está fuera de caché —han pasado más de gcTime, o es la primera vez— y el componente conserva datos anteriores, se ve la lista antigua durante el refresco.

Cómo distinguirlas:

Observación Apunta a
Ocurre siempre, incluso sin red A: es un problema de sincronización de estado
Ocurre solo a veces, y más si tardas en volver B: es un problema de caché
La URL en la barra ya trae ?tipo=electrica en el instante del parpadeo A: la URL está bien, la pantalla la ignora
En las DevTools de Query se ve la clave antigua activa B
Con la red ralentizada el parpadeo se alarga B

En el proyecto tal y como está escrito, la causa es la B, porque el apartado 8 lee el tipo directamente de useSearchParams en el render —no hay useState intermedio ni efecto de sincronización—, de modo que A está descartada por construcción.

La corrección tiene dos partes. Primero, no arrastrar datos de otra clave: TanStack Query v5 no lo hace por defecto, pero sí ocurre si alguien añadió placeholderData: keepPreviousData sin entender el efecto. Si está, se quita o se acompaña de una señal visual:

const consulta = useBicicletas({ tipo });

// Si se quiere conservar la lista anterior durante el cambio de filtro,
// hay que DECIRLO en la interfaz, no dejar que parezca el resultado final
const mostrandoAnterior = consulta.isPlaceholderData;

<ul className={clases(estilos.lista, mostrandoAnterior && estilos.atenuada)} aria-busy={mostrandoAnterior}>

Y segundo, la solución de fondo, que además mejora la experiencia: sembrar la caché de la lista filtrada a partir de la completa, ya que el filtro por tipo es un subconjunto de un dato que casi siempre está en memoria:

export function useBicicletas(filtros = {}) {
  const cliente = useQueryClient();

  return useQuery({
    queryKey: claves.bicicletas.lista(filtros),
    queryFn: ({ signal }) => obtenerBicicletas({ ...filtros, señal: signal }),
    placeholderData: () => {
      // Si la lista completa está en caché, se filtra en cliente como valor provisional
      const todas = cliente.getQueryData(claves.bicicletas.lista({}));
      if (!todas || !filtros.tipo || filtros.tipo === 'todos') return undefined;
      return todas.filter((bici) => bici.tipo === filtros.tipo);
    }
  });
}

Ahora, al volver atrás con ?tipo=electrica, se ven las dos bicicletas eléctricas al instante —filtradas de la lista completa que ya estaba en memoria— y la petición real las confirma después. Ni parpadeo ni lista equivocada.

Solución 3.

Qué problema tiene la solución del apartado 16. Es brusca y pierde trabajo. Un dispatch(sesionCerrada()) desde peticion expulsa a /acceso en el instante en que cualquier petición devuelve 401 —incluida una revalidación en segundo plano que la persona no ha pedido—, y se lleva por delante el formulario a medio escribir. Además, si varias peticiones fallan a la vez, se despachan varios cierres de sesión, y la capa de datos toma una decisión de navegación que no le corresponde.

Reparto de responsabilidades:

Capa Responsabilidad
src/api/cliente.js Detectar el 401 y lanzar un ErrorApi con estado: 401. Nada más: no despacha ni navega
clienteConsultas.js Un manejador global de errores que, ante un 401, marca la sesión como caducada (no cerrada)
sliceSesion Nuevo campo caducada, distinto de usuario === null
Componente AvisoSesionCaducada Muestra el diálogo con el botón de volver a identificarse
PaginaAcceso Al identificarse, limpia caducada y devuelve a la pantalla guardada

El código nuevo:

// src/funcionalidades/sesion/sliceSesion.js — añadidos
const estadoInicial = {
  usuario: leerSesionGuardada(),
  cargando: false,
  error: null,
  caducada: false          // hay usuario en memoria, pero el servidor ya no lo acepta
};

// dentro de reducers:
sesionCaducada(estado) {
  estado.caducada = true;   // el usuario NO se borra: se necesita para volver
},
sesionRenovada(estado, accion) {
  estado.usuario = accion.payload;
  estado.caducada = false;
},

export const seleccionarSesionCaducada = (estado) => estado.sesion.caducada;
// src/consultas/clienteConsultas.js — manejador global
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { almacen } from '../almacen/almacen.js';
import { sesionCaducada } from '../funcionalidades/sesion/sliceSesion.js';
import { ErrorApi } from '../api/cliente.js';

function manejarErrorGlobal(error) {
  if (error instanceof ErrorApi && error.estado === 401) {
    // Idempotente: aunque fallen cinco peticiones, el estado queda igual
    almacen.dispatch(sesionCaducada());
  }
}

export const clienteConsultas = new QueryClient({
  queryCache: new QueryCache({ onError: manejarErrorGlobal }),
  mutationCache: new MutationCache({ onError: manejarErrorGlobal }),
  defaultOptions: { /* …los del apartado 5… */ }
});
// src/componentes/AvisoSesionCaducada.jsx
import { useSelector, useDispatch } from 'react-redux';
import { useLocation, useNavigate } from 'react-router';
import { seleccionarSesionCaducada, sesionCerrada } from '../funcionalidades/sesion/sliceSesion.js';
import Modal from './base/Modal.jsx';
import Boton from './base/Boton.jsx';

function AvisoSesionCaducada() {
  const caducada = useSelector(seleccionarSesionCaducada);
  const location = useLocation();
  const navegar = useNavigate();
  const despachar = useDispatch();

  if (!caducada) return null;

  return (
    <Modal abierto titulo="Tu sesión ha caducado" alCerrar={() => {}}>
      <p>
        Por seguridad, la sesión se ha cerrado tras un periodo de inactividad.
        Vuelve a identificarte para continuar donde estabas.
      </p>
      <div>
        <Boton
          onClick={() => navegar('/acceso', { state: { desde: location }, replace: false })}
        >
          Volver a identificarme
        </Boton>
        <Boton
          variante="secundario"
          onClick={() => {
            despachar(sesionCerrada());
            navegar('/', { replace: true });
          }}
        >
          Salir
        </Boton>
      </div>
    </Modal>
  );
}

export default AvisoSesionCaducada;

AvisoSesionCaducada se coloca en Diseno, junto a ListaAvisos, para que esté disponible en cualquier pantalla. Y PaginaAcceso despacha sesionRenovada en lugar de sesionIniciada cuando venía de una caducidad, con lo que caducada vuelve a false y la redirección usa el state.desde que dejó el modal.

Lo que se gana con este diseño:

Antes Ahora
Expulsión inmediata al recibir un 401 Diálogo que explica lo que pasa
El formulario a medio escribir se pierde Sigue montado detrás del diálogo
Cinco peticiones fallidas, cinco cierres Una acción idempotente
La capa de datos navega La capa de datos solo informa
No hay forma de volver a lo que se hacía state.desde devuelve exactamente allí

Y la matización imprescindible, porque es la trampa mental de todo este apartado: la sesión caducada no se decide en el cliente. Se detecta cuando el servidor lo dice, con un 401, y ninguna comprobación de fecha en el navegador sustituye a eso. Si el cliente confiara en su propio reloj para decidir que el token sigue vigente, bastaría con adelantarlo para saltarse la expiración.

Conclusión

CicloUrbano ya es una aplicación de verdad. Los datos vienen de la red, se cachean, se invalidan y se muestran; la sesión existe y sobrevive a la recarga; los filtros viven en la URL y son compartibles; y cada error tiene un dueño que sabe mostrarlo.

Lo primero que queda es la tabla de asignación de estado aplicada dato por dato, que es la que responde de antemano a la pregunta que más veces aparece en un proyecto de React. Sus dos filas críticas: los recursos remotos no van a Redux, y el estado de una mutación no se duplica en un useState.

Debajo está la capa de acceso a datos, src/api/, con una regla que se cumple sin excepción: habla HTTP y no sabe nada de React. El envoltorio peticion concentra la URL base, las cabeceras, la comprobación de respuesta.ok —porque fetch no lanza con un 500—, el caso 204, el tiempo de espera con AbortController combinado con la señal de Query, y una clase ErrorApi que conserva el código para que arriba se pueda distinguir un 404 de un 500 de un fallo de red. A cambio de esa disciplina se gana poder probarla sin montar nada, reutilizarla fuera de React y tener un único punto donde añadir la autenticación real.

En TanStack Query quedan fijados unos valores por defecto que no son los del manual sino los de este proyecto: staleTime de 30 s para no lanzar una tormenta de peticiones al navegar, gcTime de 5 min, revalidación al recuperar el foco, una función de retry que no reintenta los 4xx porque la respuesta no va a cambiar, y retry: false en mutaciones porque un POST reintentado puede crear dos reservas. Encima, la fábrica de claves jerárquica, que hace que invalidar el padre alcance a los hijos por prefijo, y los hooks del proyecto con enabled para expresar «esto depende de algo que todavía no tengo» y con la signal que cancela la petición al desmontar.

De la conexión con la interfaz, tres ideas que valen para cualquier pantalla: isPending pinta el esqueleto y isFetching solo susurra, porque sustituir la lista en cada revalidación es un parpadeo injustificado; el filtro que forma parte de la clave va al servidor y la búsqueda por texto se resuelve en cliente; y el recuento de resultados lleva aria-live para que el cambio se anuncie.

La mutación de crear una reserva deja el reparto claro: la validación se ejecuta contra los datos reales de la caché, la invalidación vive en el hook porque es una consecuencia del dominio, y el aviso y la redirección viven en la página porque son decisiones de esa pantalla; la redirección usa replace para que el botón atrás no devuelva a un formulario ya enviado, y el error de una mutación nunca saca de la pantalla. La actualización optimista de confirmar y cancelar aporta sus tres pasos innegociables —cancelQueries para que una consulta en vuelo no pise el cambio, la foto para poder revertir, y el onSettled para acabar sincronizando con el servidor— y su criterio de aplicación: sí en cambios de un campo con resultado predecible, no en creaciones y nunca en operaciones con dinero.

En el lado del cliente, Redux se queda con la sesión —persistida mediante un middleware, que ve todas las acciones esté montado lo que esté montado— y con el término y el orden del catálogo, con una lista explícita de lo que se decidió dejar fuera. El contexto resuelve tema y avisos con el patrón de contextos divididos, de modo que quien solo emite avisos no se repinta nunca cuando aparecen. La URL guarda el filtro y da cuatro cosas gratis: enlace compartible, recarga fiel, botón atrás coherente y clave de caché; con el recordatorio de que la URL es entrada del usuario y hay que sanearla.

Las rutas protegidas quedan conectadas a la sesión real, con el estado cargando que evita el parpadeo de expulsión el día que la sesión se valide contra el servidor, con RequiereRol mandando a «sin permisos» y no a «acceso» —porque el problema no es de sesión—, y con la advertencia que hay que repetir siempre: esto es comodidad de interfaz, no seguridad; la autorización se comprueba en el servidor, en cada petición.

Y cierra la lección la tabla de decisión de errores, que responde a la pregunta de quién muestra qué: la pantalla ante un fallo de consulta, con reintento; un mensaje propio ante un 404; la ruta * ante una URL inexistente; errorElement ante una excepción de renderizado; LimiteDeError ante lo que ocurre fuera del enrutador; y un aviso en línea ante el fallo de una mutación, sin perder lo escrito. El principio que la ordena: cuanto más localizado sea el error, más localizada debe ser la respuesta, y en ningún caso una pantalla en blanco.

Todo esto se ha comprobado a mano, con veinte pasos y la pestaña de red abierta. Y a mano es exactamente el problema: mañana alguien cambia una línea del onSettled y nadie repetirá los veinte pasos. Pruebas del Proyecto convierte esa comprobación manual en una red de seguridad automática: el plan de pruebas con las historias de 11-01 repartidas por nivel, las unitarias de validarReserva y los reductores, las de componentes sobre TarjetaBicicleta y FormularioReserva, las de integración con MSW sobre páginas enteras, los tres flujos de Cypress, la cobertura leída con criterio, la integración continua completa y —el argumento definitivo— una regresión guiada en la que se rompe a propósito la invalidación de esta lección para ver exactamente qué prueba la caza y cuál no.

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