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
- La tabla definitiva: qué dato vive dónde
- La capa de acceso a datos:
src/api/cliente.js - Funciones por recurso
- Por qué esta capa no sabe nada de React
clienteConsultas.jsy sus valores por defecto- La fábrica de claves
- Los hooks de consulta del proyecto
- Conectar el catálogo: de los interruptores a los estados reales
- La mutación completa: crear una reserva
- Actualización optimista: confirmar y cancelar
- Redux: la sesión
- Redux: el catálogo, y qué no entra en Redux
- Contexto: tema y avisos
- El filtro en la URL con
useSearchParams - Rutas protegidas conectadas a la sesión real
- Manejo de errores de punta a punta
main.jsxdefinitivo- El flujo completo de una reserva
- Comprobación manual del recorrido
- 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
- La capa de acceso a datos:
src/api/cliente.js
src/api/cliente.jsAntes 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 |
- 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.
- 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.
clienteConsultas.js y sus valores por defecto
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
});
}
- 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.
- 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».
- 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:
- 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.
isPendingfrente aisFetching.isPendinges «no hay dato todavía» y pinta el esqueleto.isFetchinges «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.replace: trueal 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.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.
- 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.
- 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 | Sí | Cambio de un campo, resultado predecible, fallo raro y reversible |
| Confirmar una reserva | Sí | Ídem |
| Cambiar el estado de una bicicleta (taller) | Sí | Í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 |
- 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.
- 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.
- 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.
- El filtro en la URL con
useSearchParams
useSearchParamsYa 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=electricaen un chat lleva a quien lo abra exactamente a esa vista. - Recarga fiel:
F5no 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.
- 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.
- 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.
main.jsx definitivo
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.
- 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é.
- 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
useStateconuseEffect. 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 unuseEffectque hacefetch, algo se ha torcido. - Duplicar
isPendingen unuseStatepropio. Produce el botón que se queda cargando para siempre porque la rama de error olvidó ponerlo afalse. El estado de la mutación ya existe: úsalo. - Olvidar
cancelQueriesenonMutate. 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
POSTreintentado tras un tiempo de espera agotado puede crear dos reservas.retry: falseen 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
retrydebe 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
RutaProtegidacomo 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=coheteproduce 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:
- 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. - Usar
useMutationStatepara llevar el registro de las mutaciones pendientes y aplicarlas todas al recalcular la caché. - 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
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
