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

  1. Por qué los datos remotos no son estado de la aplicación
  2. Todo lo que hay que resolver a mano
  3. La API ficticia: json-server y db.json
  4. TanStack Query v5: instalación y montaje
  5. useQuery: la consulta y lo que devuelve
  6. Claves de consulta: la identidad de la caché
  7. Ciclo de vida de un dato en caché
  8. Reintentos, revalidación en foco y paginación
  9. Mutaciones con useMutation e invalidación
  10. Actualización optimista y su reversión
  11. De useFetchBicicletas a useBicicletas
  12. Cómo convive con Redux: la arquitectura final
  13. Alternativas: RTK Query, SWR y los loader de React Router

  1. 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-002 está 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 useState responden «¿cuál es el valor?». Una caché de estado de servidor responde además «¿sigue siendo cierto?».

  1. 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
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.

  1. La API ficticia: json-server y db.json

Antes 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.

npm install --save-dev json-server

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:

npx json-server db.json --port 3001

Y añade el atajo en package.json:

{
  "scripts": {
    "dev": "vite",
    "api": "json-server db.json --port 3001",
    "build": "vite build"
  }
}

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.

  1. TanStack Query v5: instalación y montaje

npm install @tanstack/react-query

Y, opcionalmente pero muy recomendable, las herramientas de desarrollo:

npm install --save-dev @tanstack/react-query-devtools

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.

  1. useQuery: la consulta y lo que devuelve

import { 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.

  1. 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 usuario

Reglas del diseño de claves:

  1. De lo general a lo específico, como una ruta. Es lo que permite invalidar por prefijo (apartado 9).
  2. Todo lo que cambie el resultado va en la clave. Si queryFn usa estacionId, ese identificador debe estar en la clave; si no, cambiar de estación mostraría los datos de la anterior.
  3. 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.
  4. 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.

  1. 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.

  1. 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.

  1. Mutaciones con useMutation e invalidación

Las 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 descendientes

Aquí 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.

  1. 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.

  1. De useFetchBicicletas a useBicicletas

Toca 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:

  1. Navegar rápido entre dos estaciones y ver la flota de la estación equivocada.
  2. Abrir dos paneles que muestran las mismas bicicletas y hacer dos peticiones idénticas.
  3. Volver a una pantalla y esperar de nuevo a que carguen datos que ya tenías.
  4. Crear una reserva y que el catálogo siga mostrando la bicicleta como disponible.
  5. Perder la conexión, recuperarla y quedarte con datos de hace diez minutos.
  6. 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.

  1. 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.

  1. Alternativas: RTK Query, SWR y los loader de React Router

TanStack 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 No No
Caché por clave Sí, generada del endpoint 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 , 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 : 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 loader de React Router (06-03): resuelven un problema distinto y complementario. Un loader carga 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: el loader precarga la consulta en el queryClient y el componente la lee con useQuery, 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:

  1. mutationFn no comprueba respuesta.ok ni falta la cabecera Content-Type. Sin la comprobación, un 500 se considera un éxito y la reversión nunca ocurre; sin la cabecera, json-server puede no interpretar el cuerpo.
  2. onMutate muta los objetos de la caché. r.estado = 'cancelada' modifica el objeto original dentro de map, así que la «foto» previas también queda alterada: revertir sería imposible aunque hubiera onError.
  3. Falta cancelQueries y falta onError. No hay reversión, y una revalidación en vuelo puede pisar el cambio optimista.
  4. invalidateQueries() sin clave invalida todas las consultas de la aplicación, provocando una recarga general innecesaria. Y debería ir en onSettled, no en onSuccess, 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 queryKeyla 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

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

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

© Copyright 2026. Todos los derechos reservados