Durante todo el módulo se ha repetido la misma advertencia con distintas palabras: el campo bicicletas está en sliceCatalogo de forma provisional, y src/datos/dominio.js lleva seis módulos fingiendo ser una base de datos. Ha llegado el momento de resolverlo, y la afirmación que lo gobierna todo es esta: los datos que vienen de un servidor no son estado de tu aplicación. No te pertenecen, se quedan obsoletos sin que nadie te avise, otras personas los cambian mientras tú los miras y llegan tarde. Guardarlos en useState o en Redux te convierte en responsable de una lista larguísima de problemas —carga, error, cancelación, condiciones de carrera, peticiones duplicadas, revalidación, invalidación, reintentos, paginación— que ya están resueltos en bibliotecas dedicadas. En esta lección montarás una API ficticia con json-server, aprenderás TanStack Query v5 de verdad —consultas, claves, ciclo de vida de la caché, mutaciones y actualización optimista con reversión—, reescribirás useFetchBicicletas como useBicicletas y compararás el antes y el después, y fijarás la arquitectura final de CicloUrbano: Redux para el estado del cliente, Query para el del servidor. Con esto se cierra el Módulo 7.
Contenido
- Por qué los datos remotos no son estado de la aplicación
- Todo lo que hay que resolver a mano
- La API ficticia:
json-serverydb.json - TanStack Query v5: instalación y montaje
useQuery: la consulta y lo que devuelve- Claves de consulta: la identidad de la caché
- Ciclo de vida de un dato en caché
- Reintentos, revalidación en foco y paginación
- Mutaciones con
useMutatione invalidación - Actualización optimista y su reversión
- De
useFetchBicicletasauseBicicletas - Cómo convive con Redux: la arquitectura final
- Alternativas: RTK Query, SWR y los
loaderde React Router
- Por qué los datos remotos no son estado de la aplicación
La distinción parece filosófica y es completamente práctica. Compara las dos categorías punto por punto.
| Estado del cliente | Estado del servidor | |
|---|---|---|
| Propiedad | Es tuyo. Nadie más puede cambiarlo | Es del servidor. Tú tienes una copia |
| Ubicación | Vive en el navegador | Vive en una base de datos remota |
| Caducidad | No caduca: es válido hasta que tú lo cambies | Caduca solo, sin avisarte |
| Sincronización | Innecesaria | Constante y nunca perfecta |
| Quién lo cambia | Solo tu código | Otros usuarios, otras pestañas, procesos automáticos |
| Cuándo está disponible | Inmediatamente | Tras una espera que puede fallar |
| Ejemplos | Tema, modal abierto, borrador, filtro | Bicicletas, estaciones, reservas, usuarios |
Un caso concreto de CicloUrbano que lo aclara todo. bici-002 figura como alquilada en tu pantalla. Mientras tú miras el catálogo, la usuaria que la tenía la devuelve en la estación Parque Norte. En ese instante:
- El servidor sabe que
bici-002estádisponible. - Tu almacén de Redux sigue diciendo
alquilada. - Redux está funcionando perfectamente: guarda exactamente lo que le dijiste que guardara.
El problema no es que Redux falle, es que el problema no es de gestión de estado, es de sincronización de una caché. Y una caché tiene preguntas que un almacén de estado no se hace nunca: ¿este dato sigue siendo válido? ¿cuándo lo vuelvo a pedir? ¿qué hago mientras llega el nuevo? ¿cuánto tiempo lo guardo si nadie lo mira?
Redux, el contexto y
useStateresponden «¿cuál es el valor?». Una caché de estado de servidor responde además «¿sigue siendo cierto?».
- Todo lo que hay que resolver a mano
Esta es la lista completa de lo que tienes que escribir tú si guardas datos remotos en useState o en un slice. Léela entera: es el argumento de la lección.
| Problema | Qué implica escribirlo a mano | ¿Lo resolviste ya? |
|---|---|---|
| Estado de carga | Una variable de fase por recurso, y pintarla | Sí, en 05-02 y en el estadoCarga de 07-04 |
| Estado de error | Guardar el mensaje, distinguir tipos, pintarlo | Sí |
| Cancelación | AbortController + pasar signal a fetch |
Sí, en 05-02 |
| Condiciones de carrera | Bandera ignorar para descartar respuestas viejas |
Sí, en 05-02 |
| Peticiones duplicadas | Que dos componentes que piden lo mismo no hagan dos peticiones | Parcialmente, con condition en 07-04 |
| Revalidación al volver a la pestaña | Oyente de visibilitychange o focus y relanzar |
No |
| Revalidación al recuperar la conexión | Oyente de online |
No (useEstadoConexion solo lo detectaba) |
| Invalidación tras escribir | Saber qué consultas deja obsoletas cada escritura y relanzarlas | No |
| Reintentos con espera creciente | Contador, temporizador exponencial, límite | No |
| Caché compartida entre componentes | Un registro global de datos ya obtenidos | No |
| Recolección de datos no usados | Liberar memoria de lo que ya nadie mira | No |
| Paginación sin parpadeo | Conservar la página anterior mientras llega la siguiente | No |
| Datos obsoletos mientras se revalida | Mostrar lo viejo y actualizar sin pantalla en blanco | No |
| Actualización optimista y reversión | Aplicar el cambio antes de la respuesta y deshacerlo si falla | No |
Los cuatro primeros los resolviste, y costaron una lección entera. Los diez restantes son los que nadie escribe porque son mucho trabajo, y son precisamente los que separan una aplicación que «va» de una que se siente rápida y fiable.
Y hay un coste oculto: cada uno de esos problemas hay que resolverlo por recurso. Bicicletas, estaciones, reservas, usuarios e incidencias, cada uno con su carga, su error, su cancelación y su invalidación. Es cuando se multiplica por cinco cuando la biblioteca deja de ser opcional.
- La API ficticia:
json-server y db.json
json-server y db.jsonAntes de nada, necesitamos un servidor de verdad. json-server levanta una API REST completa a partir de un fichero JSON, sin escribir una línea de servidor.
Crea db.json en la raíz del proyecto, con los datos canónicos de CicloUrbano:
{
"bicicletas": [
{ "id": "bici-001", "modelo": "Urbana Clásica", "tipo": "urbana", "estado": "disponible", "estacionId": "est-01", "precioHora": 2.5 },
{ "id": "bici-002", "modelo": "Eléctrica Pro", "tipo": "electrica", "estado": "alquilada", "estacionId": "est-01", "precioHora": 4.0 },
{ "id": "bici-003", "modelo": "Carga Max", "tipo": "carga", "estado": "mantenimiento", "estacionId": "est-02", "precioHora": 5.5 },
{ "id": "bici-004", "modelo": "Urbana Clásica", "tipo": "urbana", "estado": "disponible", "estacionId": "est-03", "precioHora": 2.5 },
{ "id": "bici-005", "modelo": "Eléctrica Pro", "tipo": "electrica", "estado": "disponible", "estacionId": "est-02", "precioHora": 4.0 }
],
"estaciones": [
{ "id": "est-01", "nombre": "Plaza Mayor", "barrio": "Centro", "plazas": 20 },
{ "id": "est-02", "nombre": "Parque Norte", "barrio": "Norte", "plazas": 15 },
{ "id": "est-03", "nombre": "Estación Central", "barrio": "Ensanche", "plazas": 30 }
],
"usuarios": [
{ "id": "usr-01", "nombre": "Ana Ribera", "email": "[email protected]", "rol": "cliente" },
{ "id": "usr-02", "nombre": "Marc Solé", "email": "[email protected]", "rol": "operario" }
],
"reservas": [
{ "id": "res-01", "bicicletaId": "bici-002", "usuario": "usr-01", "fechaInicio": "2026-05-04T09:00", "horas": 2, "estado": "activa" }
]
}Arráncalo en el puerto 3001, para no chocar con el 5173 de Vite:
Y añade el atajo en package.json:
A partir de aquí hacen falta dos terminales: npm run dev para la aplicación y npm run api para la API.
Lo que obtienes sin escribir nada de servidor:
| Petición | Qué hace |
|---|---|
GET /bicicletas |
Todas las bicicletas |
GET /bicicletas/bici-002 |
Una por identificador |
GET /bicicletas?estacionId=est-01 |
Filtro por campo |
GET /bicicletas?tipo=electrica&estado=disponible |
Varios filtros combinados |
GET /bicicletas?_page=1&_per_page=2 |
Paginación |
GET /reservas?_sort=fechaInicio |
Ordenación |
POST /reservas |
Crea, con el cuerpo en JSON |
PATCH /reservas/res-01 |
Modifica solo los campos enviados |
PUT /reservas/res-01 |
Sustituye el recurso completo |
DELETE /reservas/res-01 |
Elimina |
json-server escribe de verdad en db.json. Los POST y los PATCH persisten en el fichero, así que puedes recargar la página y ver que la reserva sigue ahí. Es lo que lo hace mucho más útil que un simulacro en memoria: se comporta como un servidor real, incluidos los fallos si le mandas algo mal.
Un consejo práctico: añade db.json a control de versiones pero cuenta con que cambiará al ejecutar la aplicación. Si quieres volver al estado inicial, git checkout db.json.
- TanStack Query v5: instalación y montaje
Y, opcionalmente pero muy recomendable, las herramientas de desarrollo:
TanStack Query gira alrededor de un objeto QueryClient, que es la caché: guarda los datos por clave, sabe cuándo caducan, decide cuándo revalidar y avisa a los componentes suscritos. Es el equivalente conceptual del almacén de Redux, para la otra categoría de estado.
// src/consultas/clienteConsultas.js
import { QueryClient } from '@tanstack/react-query';
export const clienteConsultas = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 s: los datos se consideran frescos este tiempo
gcTime: 5 * 60_000, // 5 min sin consumidores antes de liberar la memoria
retry: 2, // dos reintentos ante un fallo
refetchOnWindowFocus: true
}
}
});// src/main.jsx — arquitectura completa de CicloUrbano
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>
);Sobre la colocación: QueryClientProvider va por fuera del Provider de Redux por el mismo motivo que este iba por fuera del enrutador —cualquier componente puede necesitarlo, incluidos los de error de ruta— y porque un thunk de Redux podría querer invalidar consultas, mientras que lo contrario no ocurre. En la práctica los dos órdenes funcionan; lo que no puede es quedar ninguno de los dos por dentro del RouterProvider.
El QueryClient se crea fuera del componente, en su propio módulo. Si lo crearas dentro con new QueryClient() en el cuerpo de un componente, cada render crearía una caché nueva y perderías todo lo guardado.
useQuery: la consulta y lo que devuelve
useQuery: la consulta y lo que devuelveimport { useQuery } from '@tanstack/react-query';
function ListaEstaciones() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ['estaciones'],
queryFn: async () => {
const respuesta = await fetch('http://localhost:3001/estaciones');
if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
return respuesta.json();
}
});
if (isPending) return <IndicadorDeCarga mensaje="Cargando estaciones…" />;
if (isError) return <Aviso tono="error" texto={error.message} />;
return (
<>
{isFetching && <span aria-live="polite">Actualizando…</span>}
<ul>
{data.map((estacion) => (
<li key={estacion.id}>{estacion.nombre} · {estacion.barrio} · {estacion.plazas} plazas</li>
))}
</ul>
</>
);
}Los dos parámetros obligatorios:
| Parámetro | Qué es |
|---|---|
queryKey |
Un array que identifica este dato en la caché. Es la parte más importante |
queryFn |
Una función que devuelve una promesa con el dato, o lanza si falla |
Regla crítica de queryFn: debe lanzar cuando la petición no va bien. fetch no lanza ante un 404 ni un 500 —solo ante fallos de red—, así que la comprobación de respuesta.ok con su throw es obligatoria. Sin ella, Query considerará un éxito una respuesta de error y guardará en caché el mensaje del servidor como si fuera un dato.
Lo que devuelve useQuery, con las propiedades que se usan a diario:
| Propiedad | Qué significa |
|---|---|
data |
El dato, o undefined si aún no hay ninguno |
isPending |
true mientras no hay dato todavía: primera carga |
isError |
true si la última petición falló y no hay dato válido |
error |
El objeto de error lanzado por queryFn |
isFetching |
true siempre que hay una petición en vuelo, también en revalidaciones |
isSuccess |
true cuando hay dato |
isStale |
true si el dato se considera obsoleto |
refetch() |
Fuerza una nueva petición manualmente |
status |
La fase como una sola cadena: pending, error o success |
La distinción entre isPending e isFetching es la que más se falla, y es exactamente la que hace que Query se sienta rápido:
isPending: no hay nada que enseñar. Toca el indicador de carga a pantalla completa.isFetching: hay datos —quizá algo antiguos— y se están refrescando en segundo plano. Enseña los datos y, como mucho, un indicador discreto.
Si usas isFetching donde toca isPending, la pantalla se vaciará cada vez que Query revalide y habrás convertido su mejor característica en un parpadeo.
- Claves de consulta: la identidad de la caché
La queryKey es la identidad del dato en la caché. Dos componentes con la misma clave comparten la misma entrada, la misma petición y los mismos datos; con claves distintas son datos distintos.
['estaciones'] // todas las estaciones
['estaciones', 'est-02'] // una estación concreta
['estaciones', 'est-02', 'incidencias'] // sus incidencias
['bicicletas'] // todas las bicicletas
['bicicletas', { estacionId: 'est-01' }] // las de una estación
['bicicletas', { tipo: 'electrica', orden: 'precio' }] // filtradas y ordenadas
['reservas', { usuario: 'usr-01' }] // las de un usuarioReglas del diseño de claves:
- De lo general a lo específico, como una ruta. Es lo que permite invalidar por prefijo (apartado 9).
- Todo lo que cambie el resultado va en la clave. Si
queryFnusaestacionId, ese identificador debe estar en la clave; si no, cambiar de estación mostraría los datos de la anterior. - Los objetos se comparan por contenido, no por referencia, y el orden de las claves del objeto no importa:
{ tipo: 'urbana', orden: 'precio' }y{ orden: 'precio', tipo: 'urbana' }son la misma clave. Los arrays sí son sensibles al orden. - Centraliza las claves en una fábrica, para no escribirlas a mano en veinte sitios:
// 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 }]
}
};Con esta fábrica, una errata en una clave deja de ser posible, y renombrar un recurso es cambiar un fichero.
La consecuencia más visible de la clave compartida es la deduplicación automática: si Cabecera, PaginaCatalogo y ResumenFlota piden ['bicicletas'] en el mismo instante, se hace una sola petición y los tres reciben el mismo dato. Ese problema, que en 07-04 requería un condition escrito a mano, aquí no existe.
- Ciclo de vida de un dato en caché
Aquí está el modelo mental que hay que interiorizar. Un dato en la caché de Query pasa por cuatro situaciones.
stateDiagram-v2
[*] --> Obteniendo: primer useQuery con esta clave
Obteniendo --> Fresco: llega el dato
Fresco --> Obsoleto: pasa staleTime
Obsoleto --> Obteniendo: se monta un componente,<br/>vuelve el foco o se invalida
Fresco --> Inactivo: se desmonta el último consumidor
Obsoleto --> Inactivo: se desmonta el último consumidor
Inactivo --> Fresco: vuelve a montarse (aún fresco)
Inactivo --> Obteniendo: vuelve a montarse (ya obsoleto)
Inactivo --> [*]: pasa gcTime y se libera
| Situación | Qué significa | Qué hace Query |
|---|---|---|
| Fresco (fresh) | El dato se considera válido | No pide nada, ni al montar otro componente |
| Obsoleto (stale) | Podría haber cambiado | Lo sigue mostrando, y revalida en segundo plano cuando hay motivo |
| Inactivo (inactive) | Ningún componente montado lo usa | Lo guarda en memoria por si vuelve |
| Recolectado (garbage collected) | Pasó gcTime estando inactivo |
Se libera la memoria |
Las dos opciones que gobiernan todo esto:
| Opción | Qué controla | Valor por defecto |
|---|---|---|
staleTime |
Cuánto tiempo el dato se considera fresco | 0: obsoleto de inmediato |
gcTime |
Cuánto se guarda un dato inactivo antes de liberarlo | 5 * 60_000 (5 minutos) |
El valor por defecto de staleTime es 0, y sorprende a todo el mundo. Significa que el dato queda obsoleto en cuanto llega, así que Query revalidará en cuanto haya un motivo —montar un componente, volver a la pestaña—. No es un fallo: es un valor conservador, porque Query siempre muestra el dato en caché mientras revalida, así que el usuario no ve una espera, solo una actualización silenciosa. Aun así, en la mayoría de aplicaciones conviene subirlo.
Valores típicos según el tipo de dato:
| Tipo de dato | staleTime sugerido |
Razonamiento |
|---|---|---|
| Estaciones de CicloUrbano | 60 * 60_000 (1 h) |
Casi nunca cambian: nombre, barrio, plazas |
| Catálogo de bicicletas | 30_000 (30 s) |
El campo estado cambia con cada alquiler |
| Disponibilidad en tiempo real | 0 |
Debe estar siempre al día |
| Reservas del usuario | 60_000 (1 min) |
Cambian por acción del propio usuario |
| Perfil del usuario | 5 * 60_000 |
Cambia poquísimo |
| Datos de configuración | Infinity |
Solo cambian con un despliegue |
// staleTime por consulta, sobrescribiendo el valor por defecto del cliente
const { data } = useQuery({
queryKey: claves.estaciones.todas(),
queryFn: obtenerEstaciones,
staleTime: 60 * 60_000 // una hora: las estaciones no se mueven
});Una precisión frecuente de confundir: staleTime y gcTime miden cosas distintas. staleTime es «cuánto me fío del dato»; gcTime es «cuánto lo guardo cuando nadie lo mira». Un dato puede estar obsoleto y seguir en memoria durante horas, y por eso al volver a una pantalla ves los datos antiguos al instante mientras Query los refresca por detrás. Esa es exactamente la sensación de aplicación rápida que se buscaba.
- Reintentos, revalidación en foco y paginación
Reintentos
Por defecto, Query reintenta tres veces con una espera exponencial antes de dar la consulta por fallida. Se ajusta por consulta:
const { data } = useQuery({
queryKey: claves.bicicletas.todas(),
queryFn: obtenerBicicletas,
retry: (numeroDeIntento, error) => {
// No tiene sentido reintentar un 404: el recurso no existe
if (error.status === 404) return false;
return numeroDeIntento < 2;
},
retryDelay: (intento) => Math.min(1000 * 2 ** intento, 30_000)
});Reintentar un fallo de red temporal tiene sentido; reintentar un 401 o un 404, ninguno. Filtrar por el tipo de error es lo que distingue un reintento útil de tres peticiones inútiles.
Revalidación automática
Query revalida los datos obsoletos en cuatro momentos, todos configurables:
| Opción | Cuándo revalida | Por defecto |
|---|---|---|
refetchOnMount |
Al montar un componente que usa esa clave | true |
refetchOnWindowFocus |
Al volver a la pestaña del navegador | true |
refetchOnReconnect |
Al recuperar la conexión | true |
refetchInterval |
Cada N milisegundos (sondeo) | Desactivado |
La segunda es la que más impresiona la primera vez: dejas la pestaña, vuelves diez minutos después y los datos ya están al día sin que hayas hecho nada. Es uno de los diez problemas de la lista del apartado 2 que nadie escribe a mano.
Para un panel de operario que debe ver la flota casi en directo:
const { data } = useQuery({
queryKey: claves.bicicletas.lista({ estacionId }),
queryFn: () => obtenerBicicletas({ estacionId }),
refetchInterval: 15_000, // sondeo cada 15 s
refetchIntervalInBackground: false // pero no si la pestaña no está visible
});Paginación sin parpadeo
Al cambiar de página, la clave cambia, así que el dato de la página nueva no existe todavía y data sería undefined: la lista desaparecería y volvería. placeholderData lo evita.
import { useQuery, keepPreviousData } from '@tanstack/react-query';
function CatalogoPaginado() {
const [pagina, setPagina] = useState(1);
const { data, isPending, isFetching, isPlaceholderData } = useQuery({
queryKey: claves.bicicletas.lista({ pagina }),
queryFn: async () => {
const respuesta = await fetch(
`http://localhost:3001/bicicletas?_page=${pagina}&_per_page=2`
);
if (!respuesta.ok) throw new Error('No se ha podido cargar el catálogo.');
return respuesta.json();
},
placeholderData: keepPreviousData // conserva la página anterior mientras llega la nueva
});
if (isPending) return <IndicadorDeCarga mensaje="Cargando catálogo…" />;
return (
<>
<ListaBicicletas bicicletas={data.data} />
<button
type="button"
onClick={() => setPagina((p) => p + 1)}
disabled={isPlaceholderData || isFetching}
>
Siguiente
</button>
</>
);
}Con keepPreviousData, al pulsar «Siguiente» la lista anterior se mantiene visible, isPlaceholderData vale true mientras eso ocurre y el cambio se siente instantáneo. Sin ello, cada cambio de página es un parpadeo a pantalla vacía.
- Mutaciones con
useMutation e invalidación
useMutation e invalidaciónLas consultas leen; las mutaciones escriben. Y una escritura plantea una pregunta que una lectura no tiene: qué datos en caché acaban de quedarse obsoletos.
// src/consultas/useCrearReserva.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { claves } from './claves.js';
export function useCrearReserva() {
const cliente = useQueryClient();
return useMutation({
mutationFn: async (nuevaReserva) => {
const respuesta = await fetch('http://localhost:3001/reservas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nuevaReserva)
});
if (!respuesta.ok) throw new Error('No se ha podido crear la reserva.');
return respuesta.json();
},
onSuccess: (reservaCreada) => {
// La lista de reservas ha cambiado: márcala obsoleta y que se recargue
cliente.invalidateQueries({ queryKey: claves.reservas.todas() });
// Y la bicicleta ha pasado a 'alquilada': el catálogo también
cliente.invalidateQueries({ queryKey: claves.bicicletas.todas() });
}
});
}// Uso en PaginaNuevaReserva
function PaginaNuevaReserva() {
const [borrador, setBorrador] = useState({ bicicletaId: '', fechaInicio: '', horas: 1 });
const usuario = useSelector(seleccionarUsuario); // estado de cliente: Redux
const { mostrarAviso } = useAvisosAcciones();
const navegar = useNavigate();
const crearReserva = useCrearReserva(); // estado de servidor: Query
function manejarEnvio(evento) {
evento.preventDefault();
crearReserva.mutate(
{ ...borrador, usuario: usuario.id, estado: 'activa' },
{
onSuccess: (reserva) => {
mostrarAviso('exito', `Reserva ${reserva.id} creada.`);
navegar('/reservas', { replace: true });
},
onError: (fallo) => mostrarAviso('error', fallo.message)
}
);
}
return (
<form onSubmit={manejarEnvio} aria-busy={crearReserva.isPending}>
{/* … campos … */}
<button type="submit" disabled={crearReserva.isPending}>
{crearReserva.isPending ? 'Enviando…' : 'Reservar'}
</button>
</form>
);
}Lo que devuelve useMutation:
| Propiedad | Qué es |
|---|---|
mutate(variables, opciones) |
Lanza la mutación. No devuelve promesa |
mutateAsync(variables) |
Igual, pero devuelve una promesa para await |
isPending |
true mientras la escritura está en vuelo |
isError, error |
Fallo de la mutación |
isSuccess, data |
Resultado devuelto por mutationFn |
reset() |
Limpia el estado de la mutación |
invalidateQueries es la pieza clave, y funciona por prefijo:
cliente.invalidateQueries({ queryKey: ['bicicletas'] });
// Invalida ['bicicletas'], ['bicicletas', {estacionId:'est-01'}],
// ['bicicletas','detalle','bici-002']… todas las que empiecen por 'bicicletas'
cliente.invalidateQueries({ queryKey: ['bicicletas', 'detalle', 'bici-002'] });
// Solo esa
cliente.invalidateQueries({ queryKey: ['bicicletas'], exact: true });
// Solo la clave exacta, sin descendientesAquí se cobra la regla 1 del apartado 6: claves jerárquicas de lo general a lo específico, porque son las que permiten invalidar una rama entera con una línea.
Qué hace exactamente invalidar: marca las consultas como obsoletas y recarga inmediatamente las que estén activas (con componentes montados). Las inactivas se recargarán cuando alguien las vuelva a montar. No borra nada, así que el usuario sigue viendo los datos anteriores mientras llegan los nuevos.
- Actualización optimista y su reversión
Invalidar es correcto pero no es instantáneo: el usuario pulsa «Confirmar», espera a que el PATCH termine, espera a que la recarga termine, y entonces ve el cambio. Con una red lenta son dos segundos de nada.
La actualización optimista consiste en aplicar el cambio en la caché antes de que el servidor responda, y deshacerlo si falla. Es el patrón que hace que las aplicaciones buenas se sientan inmediatas.
// src/consultas/useConfirmarReserva.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { claves } from './claves.js';
export function useConfirmarReserva() {
const cliente = useQueryClient();
return useMutation({
mutationFn: async (idReserva) => {
const respuesta = await fetch(`http://localhost:3001/reservas/${idReserva}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado: 'confirmada' })
});
if (!respuesta.ok) throw new Error('No se ha podido confirmar la reserva.');
return respuesta.json();
},
// 1) ANTES de la petición: aplicar el cambio en la caché
onMutate: async (idReserva) => {
// Cancelar revalidaciones en vuelo: si no, podrían pisar el cambio optimista
await cliente.cancelQueries({ queryKey: claves.reservas.todas() });
// Guardar una foto del estado actual para poder revertir
const reservasPrevias = cliente.getQueryData(claves.reservas.todas());
// Escribir el cambio en la caché, de forma INMUTABLE
cliente.setQueryData(claves.reservas.todas(), (previas = []) =>
previas.map((reserva) =>
reserva.id === idReserva ? { ...reserva, estado: 'confirmada' } : reserva
)
);
// Lo que se devuelve aquí llega como 'contexto' a onError y onSettled
return { reservasPrevias };
},
// 2) SI FALLA: restaurar la foto
onError: (fallo, idReserva, contexto) => {
if (contexto?.reservasPrevias) {
cliente.setQueryData(claves.reservas.todas(), contexto.reservasPrevias);
}
},
// 3) PASE LO QUE PASE: sincronizar con el servidor
onSettled: () => {
cliente.invalidateQueries({ queryKey: claves.reservas.todas() });
}
});
}sequenceDiagram
participant U as Usuario
participant C as Caché de Query
participant S as API :3001
U->>C: mutate('res-01')
Note over C: onMutate:<br/>cancelar · guardar foto ·<br/>escribir 'confirmada'
C-->>U: La interfaz ya muestra «confirmada» (0 ms)
C->>S: PATCH /reservas/res-01
alt Correcto
S-->>C: 200
Note over C: onSettled: invalidar<br/>y recargar para confirmar
else Fallo
S-->>C: 500
Note over C: onError: restaurar la foto
C-->>U: Vuelve a «activa» + aviso de error
end
Los tres pasos son inseparables y cada uno resuelve un problema distinto:
| Paso | Sin él, qué pasa |
|---|---|
cancelQueries en onMutate |
Una revalidación en vuelo termina después y pisa el cambio optimista con los datos viejos |
Guardar reservasPrevias |
No hay forma de revertir: si falla, la interfaz se queda mintiendo |
onError restaurando |
Igual: el usuario cree que confirmó algo que el servidor rechazó |
onSettled invalidando |
La caché queda con lo que tú escribiste, no con lo que el servidor devolvió, y pueden diferir |
Cuándo usar actualización optimista y cuándo no:
| Usar | No usar |
|---|---|
| El fallo es muy improbable (marcar favorito, dar «me gusta») | El servidor aplica reglas que tú no puedes anticipar |
| La reversión se explica bien al usuario | El cambio dispara efectos secundarios (cobros, correos) |
| El cambio es local y visible | El resultado depende de datos que no tienes |
| La latencia molesta de verdad | Una espera de 200 ms no molesta a nadie |
Para confirmar una reserva es defendible; para crear una reserva, no tanto, porque el servidor podría rechazarla si la bicicleta acaba de alquilarla otra persona, y hacer aparecer y desaparecer una reserva es peor que esperar medio segundo. Por eso useCrearReserva invalida y useConfirmarReserva es optimista.
- De
useFetchBicicletas a useBicicletas
useFetchBicicletas a useBicicletasToca el momento de la verdad. Este era el hook de 05-06, con todo lo que había que hacer a mano:
// src/hooks/useFetchBicicletas.js — LA VERSIÓN DE 05-06
import { useState, useEffect } from 'react';
export function useFetchBicicletas(estacionId) {
const [bicicletas, setBicicletas] = useState([]);
const [fase, setFase] = useState('inactivo');
const [error, setError] = useState(null);
useEffect(() => {
if (!estacionId) {
setBicicletas([]);
setFase('inactivo');
return;
}
const controlador = new AbortController();
let ignorar = false;
async function cargar() {
setFase('cargando');
setError(null);
try {
const respuesta = await fetch(
`/api/estaciones/${estacionId}/bicicletas`,
{ signal: controlador.signal }
);
if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
const datos = await respuesta.json();
if (!ignorar) {
setBicicletas(datos);
setFase('exito');
}
} catch (fallo) {
if (fallo.name === 'AbortError') return;
if (!ignorar) {
setError(fallo.message);
setFase('error');
}
}
}
cargar();
return () => {
ignorar = true;
controlador.abort();
};
}, [estacionId]);
return { bicicletas, cargando: fase === 'cargando', error };
}Y esta es la versión con Query:
// src/consultas/useBicicletas.js
import { useQuery } from '@tanstack/react-query';
import { claves } from './claves.js';
async function obtenerBicicletas({ estacionId, signal }) {
const url = estacionId
? `http://localhost:3001/bicicletas?estacionId=${estacionId}`
: 'http://localhost:3001/bicicletas';
const respuesta = await fetch(url, { signal });
if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
return respuesta.json();
}
/**
* Bicicletas del catálogo, opcionalmente filtradas por estación.
* Devuelve el resultado completo de useQuery.
*/
export function useBicicletas(estacionId) {
return useQuery({
queryKey: claves.bicicletas.lista({ estacionId }),
queryFn: ({ signal }) => obtenerBicicletas({ estacionId, signal }),
enabled: Boolean(estacionId) || estacionId === undefined,
staleTime: 30_000
});
}// El componente, con la distinción correcta entre primera carga y revalidación
function PanelFlota({ estacionId }) {
const { data: bicicletas, isPending, isError, error, isFetching } = useBicicletas(estacionId);
if (isPending) return <IndicadorDeCarga mensaje="Cargando flota…" />;
if (isError) return <Aviso tono="error" texto={error.message} />;
return (
<Panel titulo={`Flota (${bicicletas.length})`}>
{isFetching && <span aria-live="polite">Actualizando…</span>}
<ListaBicicletas bicicletas={bicicletas} />
</Panel>
);
}El antes y el después, sin adornos:
useFetchBicicletas (05-06) |
useBicicletas (Query) |
|
|---|---|---|
| Líneas | ~45 | ~20, y 8 son la petición en sí |
| Estado de carga | Escrito a mano | Incluido |
| Estado de error | Escrito a mano | Incluido |
| Cancelación | AbortController a mano |
signal que da Query |
| Condiciones de carrera | Bandera ignorar a mano |
Imposibles por diseño |
| Caché compartida | ❌ No existe | ✅ Por clave |
| Deduplicación | ❌ Dos componentes, dos peticiones | ✅ Una petición |
| Revalidación al volver a la pestaña | ❌ | ✅ |
| Revalidación al reconectar | ❌ | ✅ |
| Reintentos | ❌ | ✅ Configurables |
| Datos previos mientras revalida | ❌ Pantalla vacía | ✅ Muestra lo viejo |
| Invalidación tras escribir | ❌ Imposible desde fuera | ✅ invalidateQueries |
| Recolección de memoria | ❌ | ✅ gcTime |
| Herramientas de inspección | ❌ | ✅ Query DevTools |
Fallos concretos que desaparecen sin escribir una línea:
- Navegar rápido entre dos estaciones y ver la flota de la estación equivocada.
- Abrir dos paneles que muestran las mismas bicicletas y hacer dos peticiones idénticas.
- Volver a una pantalla y esperar de nuevo a que carguen datos que ya tenías.
- Crear una reserva y que el catálogo siga mostrando la bicicleta como disponible.
- Perder la conexión, recuperarla y quedarte con datos de hace diez minutos.
- Acumular en memoria los datos de todas las estaciones visitadas en la sesión.
Y una consecuencia de arquitectura importante: sliceCatalogo pierde bicicletas, estadoCarga y error, además del createAsyncThunk cargarBicicletas y sus tres extraReducers. Se queda con termino y orden, que son estado de cliente de verdad. Es exactamente lo que se anunció en 07-04 y en 07-05: hacer y deshacer, tal como pasa en un proyecto real cuando se introduce una biblioteca de estado de servidor.
- Cómo convive con Redux: la arquitectura final
La regla es de una línea: Redux (o el contexto) para el estado del cliente; TanStack Query para el estado del servidor. No se solapan, no compiten y no hay que elegir.
flowchart TD
subgraph SERVIDOR["Estado del servidor · TanStack Query"]
Q1["['bicicletas', filtros]"]
Q2["['estaciones']"]
Q3["['estaciones', id, 'incidencias']"]
Q4["['reservas', {usuario}]"]
M1["useMutation<br/>crear · confirmar · cancelar"]
end
subgraph CLIENTE["Estado del cliente"]
R1["Redux · sliceSesion<br/>usuario · cargando"]
R2["Redux · sliceCatalogo<br/>termino · orden"]
C1["Contexto · tema"]
C2["Contexto · avisos"]
end
subgraph OTROS["Fuera de ambos"]
U1["URL · ?tipo="]
L1["useState local<br/>modales · borradores · selección"]
end
SERVIDOR --> V["Componentes de CicloUrbano"]
CLIENTE --> V
OTROS --> V
M1 -. "invalidateQueries" .-> Q1
M1 -. "invalidateQueries" .-> Q4
El reparto final, dato por dato:
| Dato | Dónde vive | Por qué |
|---|---|---|
| Bicicletas, estaciones, reservas, usuarios, incidencias | TanStack Query | Estado del servidor: caducan, se comparten, no son tuyos |
Usuario de la sesión, cargando |
Redux sliceSesion |
Estado de cliente: quién eres en esta pestaña |
| Término de búsqueda, orden | Redux sliceCatalogo |
Preferencias de esta sesión, compartidas entre pantallas |
| Tema visual | ContextoTema |
Ambiental, cambia una vez por sesión |
| Avisos | ContextoAvisos |
Puramente visual, con estado y acciones separados |
Filtro ?tipo= |
URL | Debe poder compartirse por enlace |
| Modales, desplegables, borradores, selección | useState local |
Estado local de interfaz y de formulario |
Un matiz sobre la sesión que merece pensarse. El usuario es un recurso del servidor —vive en /usuarios—, pero cuál de ellos eres tú en esta pestaña es estado de cliente. Un reparto habitual y muy limpio: la identidad (el token o el identificador) en Redux, y los datos del perfil con useQuery(['usuarios', id]). En CicloUrbano, con un acceso ficticio, mantener el usuario completo en sliceSesion es perfectamente razonable.
Cuánto Redux queda. Después de este movimiento, el almacén de CicloUrbano conserva sliceSesion y un sliceCatalogo reducido a dos campos. sliceReservas desaparece casi entero: las reservas son del servidor, sus transiciones son mutaciones y sus reglas de negocio son del servidor —donde siempre debieron estar—. Esa es la conclusión honesta que se anunciaba en 07-05: en muchas aplicaciones reales, una vez el estado del servidor está en su sitio, el estado de cliente que queda cabe en dos contextos. Es una conclusión legítima, y solo se puede llegar a ella habiendo entendido Redux, no evitándolo.
- Alternativas: RTK Query, SWR y los
loader de React Router
loader de React RouterTanStack Query no es la única respuesta. Estas son las cuatro opciones serias, comparadas.
| TanStack Query | RTK Query | SWR | loader de React Router |
|
|---|---|---|---|---|
| Paquete | @tanstack/react-query |
Incluido en RTK | swr |
Incluido en React Router |
| Requiere Redux | No | Sí | No | No |
| Caché por clave | Sí | Sí, generada del endpoint | Sí | No: por ruta |
| Mutaciones e invalidación | useMutation + invalidateQueries |
Endpoints con tags |
mutate manual |
action + revalidación automática |
| Optimista con reversión | Sí, onMutate/onError |
Sí, onQueryStarted |
Manual | Manual |
| Cliente generado | No, escribes tú queryFn |
Sí, desde la definición de la API | No | No |
| Herramientas | Query DevTools | Redux DevTools | Básicas | React Router DevTools |
| Tamaño | Medio | Ya lo tienes si usas RTK | Muy pequeño | Cero adicional |
| Carga antes de pintar | No (se pide al montar) | No | No | Sí: la ruta espera |
Cuándo elegir cada una:
- TanStack Query: la opción por defecto hoy. Es la más completa, la mejor documentada, funciona con cualquier forma de obtener datos y no te obliga a adoptar Redux. Es la que se ha usado en esta lección.
- RTK Query: si el proyecto ya usa Redux Toolkit. Defines la API una vez —
endpoints,providesTags,invalidatesTags— y te genera los hooks; la invalidación por etiquetas es más declarativa que la de claves. Todo aparece además en Redux DevTools, junto al resto del estado. Su inconveniente es que te ata a Redux. - SWR: si quieres lo esencial —caché, revalidación en foco, deduplicación— con la menor superficie posible. Menos funciones para mutaciones y paginación, pero muy sólida y minúscula.
- Los
loaderde React Router (06-03): resuelven un problema distinto y complementario. Unloadercarga los datos antes de pintar la ruta, eliminando la cascada «montar → pedir → esperar → pintar» y con ella el parpadeo de carga. Su límite es que la caché va por ruta, no por dato, así que no deduplican entre pantallas ni revalidan en foco. La combinación de los dos es la mejor arquitectura disponible hoy con React Router: elloaderprecarga la consulta en elqueryClienty el componente la lee conuseQuery, obteniendo carga anticipada y caché a la vez.
// El patrón combinado, en una línea de idea
export const cargadorEstacion = (cliente) => async ({ params }) => {
// Deja el dato en la caché antes de pintar la ruta
await cliente.ensureQueryData({
queryKey: claves.estaciones.detalle(params.estacionId),
queryFn: () => obtenerEstacion(params.estacionId)
});
return null; // el componente lo leerá con useQuery
};Errores Comunes y Consejos
Error 1: que queryFn no lance ante un error HTTP. fetch no lanza con un 404 ni un 500. Sin if (!respuesta.ok) throw, Query guardará el mensaje de error del servidor como si fuera el dato.
Error 2: usar isFetching donde toca isPending. La pantalla se vaciará en cada revalidación y habrás convertido la mejor característica de Query en un parpadeo.
Error 3: dejar fuera de la clave un parámetro que usa queryFn. Si estacionId no está en queryKey, cambiar de estación muestra los datos de la anterior. Todo lo que cambie el resultado va en la clave.
Error 4: crear el QueryClient dentro de un componente. new QueryClient() en el cuerpo de un componente crea una caché nueva en cada render. Va en su propio módulo, fuera.
Error 5: copiar data a un useState o a Redux. Duplica la fuente de verdad y anula la revalidación: tu copia no se entera de nada. Usa data directamente.
Error 6: olvidar cancelQueries en una actualización optimista. Una revalidación en vuelo puede terminar después y pisar tu cambio con los datos viejos. Es un fallo intermitente y muy difícil de diagnosticar.
Error 7: mutar la caché en setQueryData. El actualizador debe devolver un objeto nuevo, igual que un reductor. Mutar el objeto de la caché rompe la comparación de referencias y los componentes no se enteran.
Error 8: invalidar demasiado. invalidateQueries() sin clave invalida todo y provoca una tormenta de peticiones. Invalida solo la rama afectada.
Consejo 1: centraliza las claves en una fábrica. Elimina las erratas, hace explícita la jerarquía y convierte renombrar un recurso en cambiar un fichero.
Consejo 2: ajusta staleTime por tipo de dato. El valor por defecto de 0 es conservador. Las estaciones de CicloUrbano no cambian: una hora está bien y ahorra decenas de peticiones.
Consejo 3: usa las Query DevTools desde el primer día. Enseñan cada consulta con su clave, su estado —fresco, obsoleto, inactivo—, cuándo se pidió por última vez y qué datos tiene. Es el equivalente de Redux DevTools para esta capa.
Consejo 4: encapsula cada consulta en un hook propio. useBicicletas, useEstaciones, useReservas, useCrearReserva. Los componentes no deberían ver queryKey ni queryFn, exactamente por la misma razón por la que no deberían ver la forma del estado de Redux.
Consejo 5: no borres db.json de tu flujo de trabajo. Tener una API que responde de verdad, que persiste y que a veces falla es mucho más formativo que un simulacro que siempre funciona.
Ejercicios
Ejercicio 1. Escribe los hooks useEstaciones() y useEstacion(estacionId) sobre la API ficticia, con claves jerárquicas, un staleTime justificado para cada uno y el manejo correcto de errores. Después reescribe PaginaEstaciones para que los use, distinguiendo bien la primera carga de una revalidación.
Ejercicio 2. Este hook de mutación tiene cuatro problemas. Encuéntralos y corrígelo.
export function useCancelarReserva() {
const cliente = useQueryClient();
return useMutation({
mutationFn: (idReserva) =>
fetch(`http://localhost:3001/reservas/${idReserva}`, {
method: 'PATCH',
body: JSON.stringify({ estado: 'cancelada' })
}),
onMutate: (idReserva) => {
const previas = cliente.getQueryData(['reservas']);
const actualizadas = previas.map((r) => {
if (r.id === idReserva) r.estado = 'cancelada';
return r;
});
cliente.setQueryData(['reservas'], actualizadas);
},
onSuccess: () => {
cliente.invalidateQueries();
}
});
}Ejercicio 3. Después de este módulo, sliceReservas ha quedado casi vacío. Decide qué se queda en Redux, qué pasa a TanStack Query y qué desaparece por completo, para cada uno de estos elementos, y justifica cada decisión: (a) el array ids y el objeto entidades; (b) estadoCarga y error; (c) estadoEnvio; (d) la regla «solo una reserva activa puede confirmarse»; (e) el createAsyncThunk enviarReserva; (f) el selector seleccionarResumenReservas.
Soluciones
Solución 1.
// src/consultas/useEstaciones.js
import { useQuery } from '@tanstack/react-query';
import { claves } from './claves.js';
const BASE = 'http://localhost:3001';
async function pedirJson(url, signal) {
const respuesta = await fetch(url, { signal });
if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
return respuesta.json();
}
export function useEstaciones() {
return useQuery({
queryKey: claves.estaciones.todas(),
queryFn: ({ signal }) => pedirJson(`${BASE}/estaciones`, signal),
// Nombre, barrio y plazas no cambian en toda la sesión de un usuario
staleTime: 60 * 60_000
});
}
export function useEstacion(estacionId) {
return useQuery({
queryKey: claves.estaciones.detalle(estacionId),
queryFn: ({ signal }) => pedirJson(`${BASE}/estaciones/${estacionId}`, signal),
enabled: Boolean(estacionId), // sin id, no se lanza la consulta
staleTime: 60 * 60_000
});
}// src/funcionalidades/estaciones/PaginaEstaciones.jsx
function PaginaEstaciones() {
const { data: estaciones, isPending, isError, error, isFetching } = useEstaciones();
const [barrio, setBarrio] = useState('todos'); // estado local de interfaz
if (isPending) return <IndicadorDeCarga mensaje="Cargando estaciones…" />;
if (isError) return <Aviso tono="error" texto={error.message} />;
// Derivados: expresiones, no estado (07-01)
const visibles = estaciones.filter((est) => barrio === 'todos' || est.barrio === barrio);
const totalPlazas = visibles.reduce((suma, est) => suma + est.plazas, 0);
return (
<section>
<h1>Estaciones</h1>
{isFetching && <span aria-live="polite">Actualizando…</span>}
<p>{visibles.length} estaciones · {totalPlazas} plazas</p>
<ul>
{visibles.map((estacion) => (
<li key={estacion.id}><TarjetaEstacion estacion={estacion} /></li>
))}
</ul>
</section>
);
}enabled: Boolean(estacionId) sustituye a la guarda if (!estacionId) return; que en 05-06 había que escribir dentro del efecto. Y fíjate en que este componente resuelve el Ejercicio 2 de 07-01: sin efectos de sincronización, sin derivados guardados y con el estado del servidor donde le corresponde.
Solución 2. Los cuatro problemas:
mutationFnno compruebarespuesta.okni falta la cabeceraContent-Type. Sin la comprobación, un 500 se considera un éxito y la reversión nunca ocurre; sin la cabecera,json-serverpuede no interpretar el cuerpo.onMutatemuta los objetos de la caché.r.estado = 'cancelada'modifica el objeto original dentro demap, así que la «foto»previastambién queda alterada: revertir sería imposible aunque hubieraonError.- Falta
cancelQueriesy faltaonError. No hay reversión, y una revalidación en vuelo puede pisar el cambio optimista. invalidateQueries()sin clave invalida todas las consultas de la aplicación, provocando una recarga general innecesaria. Y debería ir enonSettled, no enonSuccess, para resincronizar también tras un fallo.
export function useCancelarReserva() {
const cliente = useQueryClient();
return useMutation({
mutationFn: async (idReserva) => {
const respuesta = await fetch(`http://localhost:3001/reservas/${idReserva}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, // 1)
body: JSON.stringify({ estado: 'cancelada' })
});
if (!respuesta.ok) throw new Error('No se ha podido cancelar la reserva.'); // 1)
return respuesta.json();
},
onMutate: async (idReserva) => {
await cliente.cancelQueries({ queryKey: claves.reservas.todas() }); // 3)
const reservasPrevias = cliente.getQueryData(claves.reservas.todas());
cliente.setQueryData(claves.reservas.todas(), (previas = []) =>
previas.map((reserva) => // 2) inmutable
reserva.id === idReserva ? { ...reserva, estado: 'cancelada' } : reserva
)
);
return { reservasPrevias };
},
onError: (fallo, idReserva, contexto) => { // 3)
if (contexto?.reservasPrevias) {
cliente.setQueryData(claves.reservas.todas(), contexto.reservasPrevias);
}
},
onSettled: () => {
cliente.invalidateQueries({ queryKey: claves.reservas.todas() }); // 4)
cliente.invalidateQueries({ queryKey: claves.bicicletas.todas() }); // la bici se libera
}
});
}El problema 2 es el más instructivo: una actualización optimista escrita con mutación es peor que no tenerla, porque destruye silenciosamente la única copia que permitía volver atrás. La inmutabilidad no es un capricho de Redux; es lo que hace posible deshacer.
Solución 3.
| Elemento | Destino | Justificación |
|---|---|---|
(a) ids y entidades |
A Query; desaparecen de Redux | Son la copia local de un recurso remoto. Query ya guarda las reservas por clave y las mantiene sincronizadas. La normalización manual deja de hacer falta: la caché de Query ya está indexada por clave, y para el acceso por identificador basta con ['reservas', id] |
(b) estadoCarga y error |
Desaparecen | Son isPending, isError y error de useQuery. Mantenerlos sería duplicar información que Query ya deriva, y con el riesgo de que se contradigan |
(c) estadoEnvio |
Desaparece | Es isPending de useMutation, ahora además por mutación en curso y no como una variable global compartida por todos los formularios |
| (d) «solo una activa puede confirmarse» | Al servidor, y en el cliente como validación de interfaz | Es una regla de negocio, y una regla de negocio que solo vive en el cliente no protege nada: es la misma lección de 06-05 sobre la autorización. En el cliente se conserva para no ofrecer un botón que va a fallar (reserva.estado === 'activa' && <button>), pero quien la hace cumplir es el PATCH |
(e) enviarReserva |
A useMutation |
Es una escritura remota. Como useCrearReserva, con invalidateQueries de reservas y de bicicletas. Se gana la invalidación, que el thunk no tenía |
(f) seleccionarResumenReservas |
Se queda como función pura, fuera de Redux | El cálculo sigue siendo útil y sigue siendo puro; lo que cambia es de dónde salen los datos. Pasa a ser resumirReservas(reservas) en src/utilidades/, invocada sobre el data de useQuery y memorizada con useMemo si el coste lo justifica (08-03). La lógica sobrevive; el acoplamiento al almacén, no |
Y el balance final: de sliceReservas no queda prácticamente nada. Eso no significa que las lecciones 07-03 a 07-05 hayan sido tiempo perdido. El modelo de acciones, reductores puros, estado normalizado y selectores es el que se usa en Zustand, en Jotai, en useReducer y —literalmente— en el setQueryData que acabas de escribir, que es un reductor con otro nombre. Lo que se ha aprendido es a clasificar antes de elegir, y el mejor resultado posible de esa clasificación es descubrir que necesitabas menos de lo que creías.
Conclusión
Los datos que vienen de un servidor no son estado de tu aplicación: no te pertenecen, caducan solos, otros los cambian mientras los miras y llegan tarde. Guardarlos en useState o en Redux no está mal por gusto, está mal porque te hace responsable de una lista de catorce problemas —carga, error, cancelación, condiciones de carrera, deduplicación, revalidación al volver a la pestaña y al reconectar, invalidación tras escribir, reintentos, caché compartida, recolección de memoria, paginación sin parpadeo, datos previos mientras se revalida, actualización optimista— multiplicada por cada recurso. Los cuatro primeros te costaron una lección entera en el módulo 5; los diez restantes son los que nadie escribe a mano.
Has montado una API ficticia real con json-server y un db.json con las cinco bicicletas, las tres estaciones, los dos usuarios y la reserva canónicos de CicloUrbano, servida en http://localhost:3001 con filtros, paginación, ordenación y escrituras que persisten. Sobre ella, TanStack Query v5: un QueryClient creado fuera de los componentes y provisto en main.jsx, useQuery con su queryKey —la identidad de la caché, jerárquica de lo general a lo específico y centralizada en una fábrica— y su queryFn que debe lanzar ante un error HTTP porque fetch no lo hace. Sabes distinguir isPending de isFetching, que es lo que separa una pantalla que parpadea de una que se siente instantánea, y conoces el ciclo de vida de un dato en caché —fresco, obsoleto, inactivo, recolectado— gobernado por staleTime («cuánto me fío») y gcTime («cuánto lo guardo cuando nadie lo mira»), con valores razonados por tipo de dato: una hora para las estaciones, treinta segundos para el catálogo, cero para la disponibilidad en directo. Y las funciones que no se escriben a mano: reintentos filtrados por tipo de error, revalidación al volver a la pestaña y al reconectar, y keepPreviousData para paginar sin vaciar la lista.
En el lado de la escritura, useMutation con invalidateQueries por prefijo —la recompensa directa de las claves jerárquicas— y la actualización optimista completa: onMutate cancelando las revalidaciones en vuelo, guardando la foto anterior y escribiendo el cambio de forma inmutable; onError restaurando esa foto; onSettled resincronizando pase lo que pase. Los tres pasos son inseparables, y una actualización optimista escrita con mutación es peor que no tenerla. useFetchBicicletas se ha convertido en useBicicletas: de cuarenta y cinco líneas a veinte, con seis clases enteras de fallo que dejan de ser posibles y siete funciones nuevas que antes no existían.
La arquitectura final de CicloUrbano queda repartida sin solapamientos: TanStack Query para bicicletas, estaciones, reservas y usuarios; Redux para sliceSesion y un sliceCatalogo reducido a termino y orden; contexto para el tema y los avisos; la URL para el filtro ?tipo=; y useState local para modales, borradores y selecciones. Y la conclusión honesta que este módulo ha ido preparando desde 07-01: cuando el estado del servidor está en su sitio, el estado de cliente que queda es mucho menos del que parecía. A esa conclusión solo se llega habiendo entendido Redux, no evitándolo.
Con esto se cierra el Módulo 7. CicloUrbano ya sabe dónde vive cada dato y por qué, tiene un almacén auditable con historial de acciones, una caché que se sincroniza sola con el servidor y una separación limpia entre lo que es suyo y lo que es del backend. Hace mucho, y lo hace bien. Lo que todavía no hace es ir rápida: hay componentes que se repintan sin necesidad, selectores y cálculos que se rehacen en cada render, listas que vuelven a pintarse enteras porque una prop cambió de identidad, y un paquete final que el navegador descarga entero antes de mostrar la primera pantalla. El Módulo 8: Optimización del Rendimiento ataca precisamente eso: cómo identificar qué renderizado sobra de verdad antes de tocar nada, React.memo para evitar repintados de componentes, useMemo y useCallback —la deuda que este módulo ha ido dejando en 07-02 y 07-05— para estabilizar valores y funciones, la división de código y la carga perezosa para que cada pantalla descargue solo lo suyo, y el React DevTools Profiler para medir en lugar de suponer. La próxima lección es Técnicas de Optimización del Rendimiento en React.
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
