Al llarg de tot el mòdul s'ha repetit el mateix avís amb paraules diferents: el camp bicicletas és a sliceCataleg de forma provisional, i src/dades/domini.js porta sis mòduls fent veure que és una base de dades. Ha arribat el moment de resoldre-ho, i l'afirmació que ho governa tot és aquesta: les dades que vénen d'un servidor no són estat de la teva aplicació. No et pertanyen, queden obsoletes sense que ningú t'ho avisi, altres persones les canvien mentre tu les mires i arriben tard. Guardar-les en useState o en Redux et converteix en responsable d'una llista llarguíssima de problemes —càrrega, error, cancel·lació, condicions de cursa, peticions duplicades, revalidació, invalidació, reintents, paginació— que ja estan resolts en biblioteques dedicades. En aquesta lliçó muntaràs una API fictícia amb json-server, aprendràs TanStack Query v5 de debò —consultes, claus, cicle de vida de la memòria cau, mutacions i actualització optimista amb reversió—, reescriuràs useFetchBicicletes com a useBicicletes i compararàs l'abans i el després, i fixaràs l'arquitectura final de CicloUrbano: Redux per a l'estat del client, Query per al del servidor. Amb això es tanca el Mòdul 7.

Contingut

  1. Per què les dades remotes no són estat de l'aplicació
  2. Tot el que cal resoldre a mà
  3. La API fictícia: json-server i db.json
  4. TanStack Query v5: instal·lació i muntatge
  5. useQuery: la consulta i el que retorna
  6. Claus de consulta: la identitat de la memòria cau
  7. Cicle de vida d'una dada a la memòria cau
  8. Reintents, revalidació en focus i paginació
  9. Mutacions amb useMutation i invalidació
  10. Actualització optimista i la seva reversió
  11. De useFetchBicicletes a useBicicletes
  12. Com conviu amb Redux: l'arquitectura final
  13. Alternatives: RTK Query, SWR i els loader de React Router

  1. Per què les dades remotes no són estat de l'aplicació

La distinció sembla filosòfica i és completament pràctica. Compara les dues categories punt per punt.

Estat del client Estat del servidor
Propietat És teu. Ningú més el pot canviar És del servidor. Tu en tens una còpia
Ubicació Viu al navegador Viu en una base de dades remota
Caducitat No caduca: és vàlid fins que tu el canvies Caduca sol, sense avisar-te
Sincronització Innecessària Constant i mai perfecta
Qui el canvia Només el teu codi Altres usuaris, altres pestanyes, processos automàtics
Quan està disponible Immediatament Després d'una espera que pot fallar
Exemples Tema, modal obert, esborrany, filtre Bicicletes, estacions, reserves, usuaris

Un cas concret de CicloUrbano que ho aclareix tot. bici-002 figura com a alquilada a la teva pantalla. Mentre tu mires el catàleg, la usuària que la tenia la retorna a l'estació Parc Nord. En aquest instant:

  • El servidor sap que bici-002 està disponible.
  • El teu magatzem de Redux continua dient alquilada.
  • Redux funciona perfectament: guarda exactament el que li vas dir que guardés.

El problema no és que Redux falli, és que el problema no és de gestió d'estat, és de sincronització d'una memòria cau. I una memòria cau té preguntes que un magatzem d'estat no es fa mai: aquesta dada continua sent vàlida? quan la torno a demanar? què faig mentre arriba la nova? quant de temps la guardo si ningú la mira?

Redux, el context i useState responen «quin és el valor?». Una memòria cau d'estat de servidor respon a més «continua sent cert?».

  1. Tot el que cal resoldre a mà

Aquesta és la llista completa del que has d'escriure tu si guardes dades remotes en useState o en un slice. Llegeix-la sencera: és l'argument de la lliçó.

Problema Què implica escriure-ho a mà Ja ho vas resoldre?
Estat de càrrega Una variable de fase per recurs, i pintar-la Sí, a 05-02 i a l'estatCarrega de 07-04
Estat d'error Guardar el missatge, distingir tipus, pintar-lo
Cancel·lació AbortController + passar signal a fetch Sí, a 05-02
Condicions de cursa Bandera ignorar per descartar respostes velles Sí, a 05-02
Peticions duplicades Que dos components que demanen el mateix no facin dues peticions Parcialment, amb condition a 07-04
Revalidació en tornar a la pestanya Escoltador de visibilitychange o focus i rellançar No
Revalidació en recuperar la connexió Escoltador de online No (useEstatConnexio només ho detectava)
Invalidació després d'escriure Saber quines consultes deixa obsoletes cada escriptura i rellançar-les No
Reintents amb espera creixent Comptador, temporitzador exponencial, límit No
Memòria cau compartida entre components Un registre global de dades ja obtingudes No
Recol·lecció de dades no usades Alliberar memòria del que ja ningú mira No
Paginació sense parpelleig Conservar la pàgina anterior mentre arriba la següent No
Dades obsoletes mentre es revalida Mostrar el vell i actualitzar sense pantalla en blanc No
Actualització optimista i reversió Aplicar el canvi abans de la resposta i desfer-lo si falla No

Els quatre primers els vas resoldre, i van costar una lliçó sencera. Els deu restants són els que ningú escriu perquè són molta feina, i són precisament els que separen una aplicació que «funciona» d'una que se sent ràpida i fiable.

I hi ha un cost ocult: cadascun d'aquests problemes s'ha de resoldre per recurs. Bicicletes, estacions, reserves, usuaris i incidències, cadascun amb la seva càrrega, el seu error, la seva cancel·lació i la seva invalidació. És quan es multiplica per cinc quan la biblioteca deixa de ser opcional.

  1. La API fictícia: json-server i db.json

Abans de res, necessitem un servidor de debò. json-server aixeca una API REST completa a partir d'un fitxer JSON, sense escriure ni una línia de servidor.

npm install --save-dev json-server

Crea db.json a l'arrel del projecte, amb les dades canòniques de CicloUrbano:

{
  "bicicletas": [
    { "id": "bici-001", "model": "Urbana Clàssica", "tipus": "urbana", "estat": "disponible", "estacioId": "est-01", "preuHora": 2.5 },
    { "id": "bici-002", "model": "Elèctrica Pro", "tipus": "electrica", "estat": "alquilada", "estacioId": "est-01", "preuHora": 4.0 },
    { "id": "bici-003", "model": "Càrrega Max", "tipus": "carga", "estat": "mantenimiento", "estacioId": "est-02", "preuHora": 5.5 },
    { "id": "bici-004", "model": "Urbana Clàssica", "tipus": "urbana", "estat": "disponible", "estacioId": "est-03", "preuHora": 2.5 },
    { "id": "bici-005", "model": "Elèctrica Pro", "tipus": "electrica", "estat": "disponible", "estacioId": "est-02", "preuHora": 4.0 }
  ],
  "estaciones": [
    { "id": "est-01", "nom": "Plaça Major", "barri": "Centre", "places": 20 },
    { "id": "est-02", "nom": "Parc Nord", "barri": "Nord", "places": 15 },
    { "id": "est-03", "nom": "Estació Central", "barri": "Eixample", "places": 30 }
  ],
  "usuarios": [
    { "id": "usr-01", "nom": "Ana Ribera", "email": "[email protected]", "rol": "cliente" },
    { "id": "usr-02", "nom": "Marc Solé", "email": "[email protected]", "rol": "operario" }
  ],
  "reservas": [
    { "id": "res-01", "bicicletaId": "bici-002", "usuari": "usr-01", "dataInici": "2026-05-04T09:00", "hores": 2, "estat": "activa" }
  ]
}

Arrenca'l al port 3001, per no xocar amb el 5173 de Vite:

npx json-server db.json --port 3001

I afegeix la drecera a package.json:

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

A partir d'aquí calen dos terminals: npm run dev per a l'aplicació i npm run api per a l'API.

El que obtens sense escriure res de servidor:

Petició Què fa
GET /bicicletas Totes les bicicletes
GET /bicicletas/bici-002 Una per identificador
GET /bicicletas?estacioId=est-01 Filtre per camp
GET /bicicletas?tipo=electrica&estado=disponible Diversos filtres combinats
GET /bicicletas?_page=1&_per_page=2 Paginació
GET /reservas?_sort=fechaInicio Ordenació
POST /reservas Crea, amb el cos en JSON
PATCH /reservas/res-01 Modifica només els camps enviats
PUT /reservas/res-01 Substitueix el recurs complet
DELETE /reservas/res-01 Elimina

json-server escriu de debò a db.json. Els POST i els PATCH persisteixen al fitxer, així que pots recarregar la pàgina i veure que la reserva continua allà. És el que el fa molt més útil que un simulacre en memòria: es comporta com un servidor real, fallades incloses si li envies alguna cosa malament.

Un consell pràctic: afegeix db.json al control de versions però compta que canviarà en executar l'aplicació. Si vols tornar a l'estat inicial, git checkout db.json.

  1. TanStack Query v5: instal·lació i muntatge

npm install @tanstack/react-query

I, opcionalment però molt recomanable, les eines de desenvolupament:

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

TanStack Query gira al voltant d'un objecte QueryClient, que és la memòria cau: guarda les dades per clau, sap quan caduquen, decideix quan revalidar i avisa els components subscrits. És l'equivalent conceptual del magatzem de Redux, per a l'altra categoria d'estat.

// src/consultes/clientConsultes.js
import { QueryClient } from '@tanstack/react-query';

export const clientConsultes = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,        // 30 s: les dades es consideren fresques aquest temps
      gcTime: 5 * 60_000,       // 5 min sense consumidors abans d'alliberar la memòria
      retry: 2,                 // dos reintents davant d'una fallada
      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 { magatzem } from './magatzem/magatzem.js';
import { clientConsultes } from './consultes/clientConsultes.js';
import { router } from './rutes.jsx';
import LimitError from './components/LimitError.jsx';
import { Proveidors } from './contextos/Proveidors.jsx';
import { registrarError } from './utilitats/monitoritzacio.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LimitError titol="CicloUrbano no està disponible ara mateix" alRegistrar={registrarError}>
      <QueryClientProvider client={clientConsultes}>
        <Provider store={magatzem}>
          <Proveidors>
            <RouterProvider router={router} />
          </Proveidors>
        </Provider>
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </LimitError>
  </StrictMode>
);

Sobre la col·locació: QueryClientProvider va per fora del Provider de Redux pel mateix motiu que aquest anava per fora de l'enrutador —qualsevol component el pot necessitar, inclosos els d'error de ruta— i perquè un thunk de Redux podria voler invalidar consultes, mentre que el contrari no passa. A la pràctica els dos ordres funcionen; el que no pot passar és que cap dels dos quedi per dins del RouterProvider.

El QueryClient es crea fora del component, en el seu propi mòdul. Si el creessis a dins amb new QueryClient() al cos d'un component, cada render crearia una memòria cau nova i perdries tot el que hi havia guardat.

  1. useQuery: la consulta i el que retorna

import { useQuery } from '@tanstack/react-query';

function LlistaEstacions() {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ['estacions'],
    queryFn: async () => {
      const resposta = await fetch('http://localhost:3001/estaciones');
      if (!resposta.ok) throw new Error(`El servidor ha respost ${resposta.status}`);
      return resposta.json();
    }
  });

  if (isPending) return <IndicadorDeCarrega missatge="Carregant estacions…" />;
  if (isError) return <Avis to="error" text={error.message} />;

  return (
    <>
      {isFetching && <span aria-live="polite">Actualitzant…</span>}
      <ul>
        {data.map((estacio) => (
          <li key={estacio.id}>{estacio.nom} · {estacio.barri} · {estacio.places} places</li>
        ))}
      </ul>
    </>
  );
}

Els dos paràmetres obligatoris:

Paràmetre Què és
queryKey Un array que identifica aquesta dada a la memòria cau. És la part més important
queryFn Una funció que retorna una promesa amb la dada, o llança si falla

Regla crítica de queryFn: ha de llançar quan la petició no va bé. fetch no llança davant d'un 404 ni un 500 —només davant de fallades de xarxa—, així que la comprovació de resposta.ok amb el seu throw és obligatòria. Sense ella, Query considerarà un èxit una resposta d'error i guardarà a la memòria cau el missatge del servidor com si fos una dada.

El que retorna useQuery, amb les propietats que es fan servir cada dia:

Propietat Què significa
data La dada, o undefined si encara no n'hi ha cap
isPending true mentre encara no hi ha dada: primera càrrega
isError true si l'última petició ha fallat i no hi ha dada vàlida
error L'objecte d'error llançat per queryFn
isFetching true sempre que hi ha una petició en curs, també en revalidacions
isSuccess true quan hi ha dada
isStale true si la dada es considera obsoleta
refetch() Força una petició nova manualment
status La fase com una sola cadena: pending, error o success

La distinció entre isPending i isFetching és la que més es falla, i és exactament la que fa que Query se senti ràpid:

  • isPending: no hi ha res a mostrar. Toca l'indicador de càrrega a pantalla completa.
  • isFetching: hi ha dades —potser una mica antigues— i s'estan refrescant en segon pla. Mostra les dades i, com a molt, un indicador discret.

Si uses isFetching on toca isPending, la pantalla es buidarà cada vegada que Query revalidi i hauràs convertit la seva millor característica en un parpelleig.

  1. Claus de consulta: la identitat de la memòria cau

La queryKey és la identitat de la dada a la memòria cau. Dos components amb la mateixa clau comparteixen la mateixa entrada, la mateixa petició i les mateixes dades; amb claus diferents són dades diferents.

['estacions']                                     // totes les estacions
['estacions', 'est-02']                           // una estació concreta
['estacions', 'est-02', 'incidencies']            // les seves incidències
['bicicletes']                                     // totes les bicicletes
['bicicletes', { estacioId: 'est-01' }]            // les d'una estació
['bicicletes', { tipo: 'electrica', ordre: 'preu' }]      // filtrades i ordenades
['reserves', { usuari: 'usr-01' }]                 // les d'un usuari

Regles del disseny de claus:

  1. Del general a l'específic, com una ruta. És el que permet invalidar per prefix (apartat 9).
  2. Tot el que canviï el resultat va a la clau. Si queryFn usa estacioId, aquest identificador ha d'estar a la clau; si no, canviar d'estació mostraria les dades de l'anterior.
  3. Els objectes es comparen per contingut, no per referència, i l'ordre de les claus de l'objecte no importa: { tipo: 'urbana', ordre: 'preu' } i { ordre: 'preu', tipo: 'urbana' } són la mateixa clau. Els arrays sí que són sensibles a l'ordre.
  4. Centralitza les claus en una fàbrica, per no escriure-les a mà en vint llocs:
// src/consultes/claus.js
export const claus = {
  bicicletes: {
    totes: () => ['bicicletes'],
    llista: (filtres) => ['bicicletes', filtres],
    detall: (id) => ['bicicletes', 'detall', id]
  },
  estacions: {
    totes: () => ['estacions'],
    detall: (id) => ['estacions', id],
    incidencies: (id) => ['estacions', id, 'incidencies']
  },
  reserves: {
    totes: () => ['reserves'],
    deUsuari: (usuariId) => ['reserves', { usuari: usuariId }]
  }
};

Amb aquesta fàbrica, una errada en una clau deixa de ser possible, i canviar el nom d'un recurs és canviar un fitxer.

La conseqüència més visible de la clau compartida és la deduplicació automàtica: si Capcalera, PaginaCataleg i ResumFlota demanen ['bicicletes'] en el mateix instant, es fa una sola petició i els tres reben la mateixa dada. Aquest problema, que a 07-04 requeria un condition escrit a mà, aquí no existeix.

  1. Cicle de vida d'una dada a la memòria cau

Aquí hi ha el model mental que cal interioritzar. Una dada a la memòria cau de Query passa per quatre situacions.

stateDiagram-v2
    [*] --> Obtenint: primer useQuery amb aquesta clau
    Obtenint --> Fresc: arriba la dada
    Fresc --> Obsolet: passa staleTime
    Obsolet --> Obtenint: es munta un component,<br/>torna el focus o s'invalida
    Fresc --> Inactiu: es desmunta l'últim consumidor
    Obsolet --> Inactiu: es desmunta l'últim consumidor
    Inactiu --> Fresc: es torna a muntar (encara fresc)
    Inactiu --> Obtenint: es torna a muntar (ja obsolet)
    Inactiu --> [*]: passa gcTime i s'allibera
Situació Què significa Què fa Query
Fresc (fresh) La dada es considera vàlida No demana res, ni en muntar un altre component
Obsolet (stale) Podria haver canviat La continua mostrant, i revalida en segon pla quan hi ha motiu
Inactiu (inactive) Cap component muntat la fa servir La guarda en memòria per si torna
Recol·lectat (garbage collected) Va passar gcTime estant inactiu S'allibera la memòria

Les dues opcions que ho governen tot això:

Opció Què controla Valor per defecte
staleTime Quant de temps la dada es considera fresca 0: obsoleta de seguida
gcTime Quant es guarda una dada inactiva abans d'alliberar-la 5 * 60_000 (5 minuts)

El valor per defecte de staleTime és 0, i sorprèn tothom. Significa que la dada queda obsoleta tan bon punt arriba, així que Query revalidarà tan bon punt hi hagi un motiu —muntar un component, tornar a la pestanya—. No és una fallada: és un valor conservador, perquè Query sempre mostra la dada en memòria cau mentre revalida, així que l'usuari no veu cap espera, només una actualització silenciosa. Tot i així, en la majoria d'aplicacions convé pujar-lo.

Valors típics segons el tipus de dada:

Tipus de dada staleTime suggerit Raonament
Estacions de CicloUrbano 60 * 60_000 (1 h) Gairebé mai canvien: nom, barri, places
Catàleg de bicicletes 30_000 (30 s) El camp estado canvia amb cada lloguer
Disponibilitat en temps real 0 Ha d'estar sempre al dia
Reserves de l'usuari 60_000 (1 min) Canvien per acció del mateix usuari
Perfil de l'usuari 5 * 60_000 Canvia molt poc
Dades de configuració Infinity Només canvien amb un desplegament
// staleTime per consulta, sobreescrivint el valor per defecte del client
const { data } = useQuery({
  queryKey: claus.estacions.totes(),
  queryFn: obtenirEstacions,
  staleTime: 60 * 60_000    // una hora: les estacions no es mouen
});

Una precisió que sovint es confon: staleTime i gcTime mesuren coses diferents. staleTime és «com me'n refio de la dada»; gcTime és «quant la guardo quan ningú la mira». Una dada pot estar obsoleta i continuar en memòria durant hores, i per això en tornar a una pantalla veus les dades antigues a l'instant mentre Query les refresca per darrere. Aquesta és exactament la sensació d'aplicació ràpida que es buscava.

  1. Reintents, revalidació en focus i paginació

Reintents

Per defecte, Query reintenta tres vegades amb una espera exponencial abans de donar la consulta per fallida. S'ajusta per consulta:

const { data } = useQuery({
  queryKey: claus.bicicletes.totes(),
  queryFn: obtenirBicicletes,
  retry: (numeroIntent, error) => {
    // No té sentit reintentar un 404: el recurs no existeix
    if (error.status === 404) return false;
    return numeroIntent < 2;
  },
  retryDelay: (intent) => Math.min(1000 * 2 ** intent, 30_000)
});

Reintentar una fallada de xarxa temporal té sentit; reintentar un 401 o un 404, cap. Filtrar pel tipus d'error és el que distingeix un reintent útil de tres peticions inútils.

Revalidació automàtica

Query revalida les dades obsoletes en quatre moments, tots configurables:

Opció Quan revalida Per defecte
refetchOnMount En muntar un component que usa aquesta clau true
refetchOnWindowFocus En tornar a la pestanya del navegador true
refetchOnReconnect En recuperar la connexió true
refetchInterval Cada N mil·lisegons (sondeig) Desactivat

La segona és la que més impressiona la primera vegada: deixes la pestanya, tornes deu minuts després i les dades ja estan al dia sense que hagis fet res. És un dels deu problemes de la llista de l'apartat 2 que ningú escriu a mà.

Per a un panell d'operari que ha de veure la flota gairebé en directe:

const { data } = useQuery({
  queryKey: claus.bicicletes.llista({ estacioId }),
  queryFn: () => obtenirBicicletes({ estacioId }),
  refetchInterval: 15_000,          // sondeig cada 15 s
  refetchIntervalInBackground: false // però no si la pestanya no és visible
});

Paginació sense parpelleig

En canviar de pàgina, la clau canvia, així que la dada de la pàgina nova encara no existeix i data seria undefined: la llista desapareixeria i tornaria. placeholderData ho evita.

import { useQuery, keepPreviousData } from '@tanstack/react-query';

function CatalegPaginat() {
  const [pagina, setPagina] = useState(1);

  const { data, isPending, isFetching, isPlaceholderData } = useQuery({
    queryKey: claus.bicicletes.llista({ pagina }),
    queryFn: async () => {
      const resposta = await fetch(
        `http://localhost:3001/bicicletas?_page=${pagina}&_per_page=2`
      );
      if (!resposta.ok) throw new Error('No s\'ha pogut carregar el catàleg.');
      return resposta.json();
    },
    placeholderData: keepPreviousData   // conserva la pàgina anterior mentre arriba la nova
  });

  if (isPending) return <IndicadorDeCarrega missatge="Carregant catàleg…" />;

  return (
    <>
      <LlistaBicicletes bicicletes={data.data} />
      <button
        type="button"
        onClick={() => setPagina((p) => p + 1)}
        disabled={isPlaceholderData || isFetching}
      >
        Següent
      </button>
    </>
  );
}

Amb keepPreviousData, en prémer «Següent» la llista anterior es manté visible, isPlaceholderData val true mentre això passa i el canvi se sent instantani. Sense això, cada canvi de pàgina és un parpelleig a pantalla buida.

  1. Mutacions amb useMutation i invalidació

Les consultes llegeixen; les mutacions escriuen. I una escriptura planteja una pregunta que una lectura no té: quines dades a la memòria cau acaben de quedar obsoletes.

// src/consultes/useCrearReserva.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { claus } from './claus.js';

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

  return useMutation({
    mutationFn: async (novaReserva) => {
      const resposta = await fetch('http://localhost:3001/reservas', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(novaReserva)
      });
      if (!resposta.ok) throw new Error('No s\'ha pogut crear la reserva.');
      return resposta.json();
    },

    onSuccess: (reservaCreada) => {
      // La llista de reserves ha canviat: marca-la obsoleta i que es recarregui
      client.invalidateQueries({ queryKey: claus.reserves.totes() });
      // I la bicicleta ha passat a 'alquilada': el catàleg també
      client.invalidateQueries({ queryKey: claus.bicicletes.totes() });
    }
  });
}
// Ús a PaginaNovaReserva
function PaginaNovaReserva() {
  const [esborrany, setEsborrany] = useState({ bicicletaId: '', fechaInicio: '', horas: 1 });
  const usuari = useSelector(seleccionarUsuari);         // estat de client: Redux
  const { mostrarAvis } = useAvisosAccions();
  const navegar = useNavigate();

  const crearReserva = useCrearReserva();                 // estat de servidor: Query

  function gestionarEnviament(esdeveniment) {
    esdeveniment.preventDefault();
    crearReserva.mutate(
      { ...esborrany, usuario: usuari.id, estado: 'activa' },
      {
        onSuccess: (reserva) => {
          mostrarAvis('exit', `Reserva ${reserva.id} creada.`);
          navegar('/reservas', { replace: true });
        },
        onError: (fallada) => mostrarAvis('error', fallada.message)
      }
    );
  }

  return (
    <form onSubmit={gestionarEnviament} aria-busy={crearReserva.isPending}>
      {/* … camps … */}
      <button type="submit" disabled={crearReserva.isPending}>
        {crearReserva.isPending ? 'Enviant…' : 'Reservar'}
      </button>
    </form>
  );
}

El que retorna useMutation:

Propietat Què és
mutate(variables, opcions) Llança la mutació. No retorna promesa
mutateAsync(variables) Igual, però retorna una promesa per fer-hi await
isPending true mentre l'escriptura és en curs
isError, error Fallada de la mutació
isSuccess, data Resultat retornat per mutationFn
reset() Neteja l'estat de la mutació

invalidateQueries és la peça clau, i funciona per prefix:

client.invalidateQueries({ queryKey: ['bicicletes'] });
// Invalida ['bicicletes'], ['bicicletes', {estacioId:'est-01'}],
// ['bicicletes','detall','bici-002']… totes les que comencin per 'bicicletes'

client.invalidateQueries({ queryKey: ['bicicletes', 'detall', 'bici-002'] });
// Només aquesta

client.invalidateQueries({ queryKey: ['bicicletes'], exact: true });
// Només la clau exacta, sense descendents

Aquí es cobra la regla 1 de l'apartat 6: claus jeràrquiques del general a l'específic, perquè són les que permeten invalidar una branca sencera amb una línia.

Què fa exactament invalidar: marca les consultes com a obsoletes i recarrega immediatament les que estiguin actives (amb components muntats). Les inactives es recarregaran quan algú les torni a muntar. No esborra res, així que l'usuari continua veient les dades anteriors mentre arriben les noves.

  1. Actualització optimista i la seva reversió

Invalidar és correcte però no és instantani: l'usuari prem «Confirmar», espera que el PATCH acabi, espera que la recàrrega acabi, i llavors veu el canvi. Amb una xarxa lenta són dos segons de no-res.

L'actualització optimista consisteix a aplicar el canvi a la memòria cau abans que el servidor respongui, i desfer-lo si falla. És el patró que fa que les aplicacions bones se sentin immediates.

// src/consultes/useConfirmarReserva.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { claus } from './claus.js';

export function useConfirmarReserva() {
  const client = useQueryClient();

  return useMutation({
    mutationFn: async (idReserva) => {
      const resposta = await fetch(`http://localhost:3001/reservas/${idReserva}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ estado: 'confirmada' })
      });
      if (!resposta.ok) throw new Error('No s\'ha pogut confirmar la reserva.');
      return resposta.json();
    },

    // 1) ABANS de la petició: aplicar el canvi a la memòria cau
    onMutate: async (idReserva) => {
      // Cancel·lar revalidacions en curs: si no, podrien trepitjar el canvi optimista
      await client.cancelQueries({ queryKey: claus.reserves.totes() });

      // Guardar una foto de l'estat actual per poder revertir
      const reservesPrevies = client.getQueryData(claus.reserves.totes());

      // Escriure el canvi a la memòria cau, de forma IMMUTABLE
      client.setQueryData(claus.reserves.totes(), (previes = []) =>
        previes.map((reserva) =>
          reserva.id === idReserva ? { ...reserva, estado: 'confirmada' } : reserva
        )
      );

      // El que es retorna aquí arriba com a 'context' a onError i onSettled
      return { reservesPrevies };
    },

    // 2) SI FALLA: restaurar la foto
    onError: (fallada, idReserva, context) => {
      if (context?.reservesPrevies) {
        client.setQueryData(claus.reserves.totes(), context.reservesPrevies);
      }
    },

    // 3) PASSI EL QUE PASSI: sincronitzar amb el servidor
    onSettled: () => {
      client.invalidateQueries({ queryKey: claus.reserves.totes() });
    }
  });
}
sequenceDiagram
    participant U as Usuari
    participant C as Memòria cau de Query
    participant S as API :3001
    U->>C: mutate('res-01')
    Note over C: onMutate:<br/>cancel·lar · guardar foto ·<br/>escriure 'confirmada'
    C-->>U: La interfície ja mostra «confirmada» (0 ms)
    C->>S: PATCH /reservas/res-01
    alt Correcte
        S-->>C: 200
        Note over C: onSettled: invalidar<br/>i recarregar per confirmar
    else Fallada
        S-->>C: 500
        Note over C: onError: restaurar la foto
        C-->>U: Torna a «activa» + avís d'error
    end

Els tres passos són inseparables i cadascun resol un problema diferent:

Pas Sense ell, què passa
cancelQueries a onMutate Una revalidació en curs acaba després i trepitja el canvi optimista amb les dades velles
Guardar reservesPrevies No hi ha manera de revertir: si falla, la interfície es queda mentint
onError restaurant Igual: l'usuari creu que ha confirmat una cosa que el servidor ha rebutjat
onSettled invalidant La memòria cau queda amb el que tu vas escriure, no amb el que el servidor ha retornat, i poden diferir

Quan usar actualització optimista i quan no:

Usar-la No usar-la
La fallada és molt improbable (marcar favorit, donar «m'agrada») El servidor aplica regles que tu no pots anticipar
La reversió s'explica bé a l'usuari El canvi dispara efectes secundaris (cobraments, correus)
El canvi és local i visible El resultat depèn de dades que no tens
La latència molesta de debò Una espera de 200 ms no molesta ningú

Per confirmar una reserva és defensable; per crear una reserva, no tant, perquè el servidor la podria rebutjar si la bicicleta l'acaba de llogar una altra persona, i fer aparèixer i desaparèixer una reserva és pitjor que esperar mig segon. Per això useCrearReserva invalida i useConfirmarReserva és optimista.

  1. De useFetchBicicletes a useBicicletes

Toca el moment de la veritat. Aquest era el hook de 05-06, amb tot el que calia fer a mà:

// src/hooks/useFetchBicicletes.js — LA VERSIÓ DE 05-06
import { useState, useEffect } from 'react';

export function useFetchBicicletes(estacioId) {
  const [bicicletes, setBicicletes] = useState([]);
  const [fase, setFase] = useState('inactiu');
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!estacioId) {
      setBicicletes([]);
      setFase('inactiu');
      return;
    }

    const controlador = new AbortController();
    let ignorar = false;

    async function carregar() {
      setFase('carregant');
      setError(null);
      try {
        const resposta = await fetch(
          `/api/estaciones/${estacioId}/bicicletas`,
          { signal: controlador.signal }
        );
        if (!resposta.ok) throw new Error(`El servidor ha respost ${resposta.status}`);
        const dades = await resposta.json();
        if (!ignorar) {
          setBicicletes(dades);
          setFase('exit');
        }
      } catch (fallada) {
        if (fallada.name === 'AbortError') return;
        if (!ignorar) {
          setError(fallada.message);
          setFase('error');
        }
      }
    }

    carregar();

    return () => {
      ignorar = true;
      controlador.abort();
    };
  }, [estacioId]);

  return { bicicletes, carregant: fase === 'carregant', error };
}

I aquesta és la versió amb Query:

// src/consultes/useBicicletes.js
import { useQuery } from '@tanstack/react-query';
import { claus } from './claus.js';

async function obtenirBicicletes({ estacioId, signal }) {
  const url = estacioId
    ? `http://localhost:3001/bicicletas?estacioId=${estacioId}`
    : 'http://localhost:3001/bicicletas';

  const resposta = await fetch(url, { signal });
  if (!resposta.ok) throw new Error(`El servidor ha respost ${resposta.status}`);
  return resposta.json();
}

/**
 * Bicicletes del catàleg, opcionalment filtrades per estació.
 * Retorna el resultat complet de useQuery.
 */
export function useBicicletes(estacioId) {
  return useQuery({
    queryKey: claus.bicicletes.llista({ estacioId }),
    queryFn: ({ signal }) => obtenirBicicletes({ estacioId, signal }),
    enabled: Boolean(estacioId) || estacioId === undefined,
    staleTime: 30_000
  });
}
// El component, amb la distinció correcta entre primera càrrega i revalidació
function PanellFlota({ estacioId }) {
  const { data: bicicletes, isPending, isError, error, isFetching } = useBicicletes(estacioId);

  if (isPending) return <IndicadorDeCarrega missatge="Carregant flota…" />;
  if (isError) return <Avis to="error" text={error.message} />;

  return (
    <Panell titol={`Flota (${bicicletes.length})`}>
      {isFetching && <span aria-live="polite">Actualitzant…</span>}
      <LlistaBicicletes bicicletes={bicicletes} />
    </Panell>
  );
}

L'abans i el després, sense adorns:

useFetchBicicletes (05-06) useBicicletes (Query)
Línies ~45 ~20, i 8 són la petició en si
Estat de càrrega Escrit a mà Inclòs
Estat d'error Escrit a mà Inclòs
Cancel·lació AbortController a mà signal que dona Query
Condicions de cursa Bandera ignorar a mà Impossibles per disseny
Memòria cau compartida ❌ No existeix ✅ Per clau
Deduplicació ❌ Dos components, dues peticions ✅ Una petició
Revalidació en tornar a la pestanya
Revalidació en reconnectar
Reintents ✅ Configurables
Dades prèvies mentre revalida ❌ Pantalla buida ✅ Mostra el vell
Invalidació després d'escriure ❌ Impossible des de fora invalidateQueries
Recol·lecció de memòria gcTime
Eines d'inspecció ✅ Query DevTools

Fallades concretes que desapareixen sense escriure ni una línia:

  1. Navegar ràpid entre dues estacions i veure la flota de l'estació equivocada.
  2. Obrir dos panells que mostren les mateixes bicicletes i fer dues peticions idèntiques.
  3. Tornar a una pantalla i esperar de nou que carreguin dades que ja tenies.
  4. Crear una reserva i que el catàleg continuï mostrant la bicicleta com a disponible.
  5. Perdre la connexió, recuperar-la i quedar-te amb dades de fa deu minuts.
  6. Acumular en memòria les dades de totes les estacions visitades en la sessió.

I una conseqüència d'arquitectura important: sliceCataleg perd bicicletes, estatCarrega i error, a més del createAsyncThunk carregarBicicletes i els seus tres extraReducers. Es queda amb terme i ordre, que són estat de client de veritat. És exactament el que s'anunciava a 07-04 i a 07-05: fer i desfer, tal com passa en un projecte real quan s'introdueix una biblioteca d'estat de servidor.

  1. Com conviu amb Redux: l'arquitectura final

La regla és d'una línia: Redux (o el context) per a l'estat del client; TanStack Query per a l'estat del servidor. No se solapen, no competeixen i no cal triar.

flowchart TD
    subgraph SERVIDOR["Estat del servidor · TanStack Query"]
        Q1["['bicicletes', filtres]"]
        Q2["['estacions']"]
        Q3["['estacions', id, 'incidencies']"]
        Q4["['reserves', {usuari}]"]
        M1["useMutation<br/>crear · confirmar · cancel·lar"]
    end
    subgraph CLIENT["Estat del client"]
        R1["Redux · sliceSessio<br/>usuari · carregant"]
        R2["Redux · sliceCataleg<br/>terme · ordre"]
        C1["Context · tema"]
        C2["Context · avisos"]
    end
    subgraph ALTRES["Fora de tots dos"]
        U1["URL · ?tipo="]
        L1["useState local<br/>modals · esborranys · selecció"]
    end
    SERVIDOR --> V["Components de CicloUrbano"]
    CLIENT --> V
    ALTRES --> V
    M1 -. "invalidateQueries" .-> Q1
    M1 -. "invalidateQueries" .-> Q4

El repartiment final, dada per dada:

Dada On viu Per què
Bicicletes, estacions, reserves, usuaris, incidències TanStack Query Estat del servidor: caduquen, es comparteixen, no són teus
Usuari de la sessió, carregant Redux sliceSessio Estat de client: qui ets en aquesta pestanya
Terme de cerca, ordre Redux sliceCataleg Preferències d'aquesta sessió, compartides entre pantalles
Tema visual ContextTema Ambiental, canvia un cop per sessió
Avisos ContextAvisos Purament visual, amb estat i accions separats
Filtre ?tipo= URL Ha de poder compartir-se per enllaç
Modals, desplegables, esborranys, selecció useState local Estat local d'interfície i de formulari

Un matís sobre la sessió que val la pena pensar. L'usuari és un recurs del servidor —viu a /usuarios—, però quin d'ells ets tu en aquesta pestanya és estat de client. Un repartiment habitual i molt net: la identitat (el token o l'identificador) a Redux, i les dades del perfil amb useQuery(['usuaris', id]). A CicloUrbano, amb un accés fictici, mantenir l'usuari complet a sliceSessio és perfectament raonable.

Quant Redux queda. Després d'aquest moviment, el magatzem de CicloUrbano conserva sliceSessio i un sliceCataleg reduït a dos camps. sliceReserves desapareix gairebé sencer: les reserves són del servidor, les seves transicions són mutacions i les seves regles de negoci són del servidor —on sempre havien d'haver estat—. Aquesta és la conclusió honesta que s'anunciava a 07-05: en moltes aplicacions reals, un cop l'estat del servidor és al seu lloc, l'estat de client que queda cap en dos contextos. És una conclusió legítima, i només s'hi pot arribar havent entès Redux, no evitant-lo.

  1. Alternatives: RTK Query, SWR i els loader de React Router

TanStack Query no és l'única resposta. Aquestes són les quatre opcions serioses, comparades.

TanStack Query RTK Query SWR loader de React Router
Paquet @tanstack/react-query Inclòs a RTK swr Inclòs a React Router
Requereix Redux No No No
Memòria cau per clau Sí, generada de l'endpoint No: per ruta
Mutacions i invalidació useMutation + invalidateQueries Endpoints amb tags mutate manual action + revalidació automàtica
Optimista amb reversió Sí, onMutate/onError Sí, onQueryStarted Manual Manual
Client generat No, escrius tu la queryFn , a partir de la definició de l'API No No
Eines Query DevTools Redux DevTools Bàsiques React Router DevTools
Mida Mitjana Ja la tens si uses RTK Molt petita Zero addicional
Càrrega abans de pintar No (es demana en muntar) No No : la ruta espera

Quan triar cadascuna:

  • TanStack Query: l'opció per defecte avui. És la més completa, la més ben documentada, funciona amb qualsevol manera d'obtenir dades i no t'obliga a adoptar Redux. És la que s'ha fet servir en aquesta lliçó.
  • RTK Query: si el projecte ja usa Redux Toolkit. Defineixes l'API un cop —endpoints, providesTags, invalidatesTags— i et genera els hooks; la invalidació per etiquetes és més declarativa que la de claus. Tot apareix a més a Redux DevTools, junt amb la resta de l'estat. El seu inconvenient és que et lliga a Redux.
  • SWR: si vols l'essencial —memòria cau, revalidació en focus, deduplicació— amb la mínima superfície possible. Menys funcions per a mutacions i paginació, però molt sòlida i minúscula.
  • Els loader de React Router (06-03): resolen un problema diferent i complementari. Un loader carrega les dades abans de pintar la ruta, eliminant la cascada «muntar → demanar → esperar → pintar» i amb ella el parpelleig de càrrega. El seu límit és que la memòria cau va per ruta, no per dada, així que no dedupliquen entre pantalles ni revaliden en focus. La combinació dels dos és la millor arquitectura disponible avui amb React Router: el loader precarrega la consulta al queryClient i el component la llegeix amb useQuery, obtenint càrrega anticipada i memòria cau alhora.
// El patró combinat, en una línia d'idea
export const carregadorEstacio = (client) => async ({ params }) => {
  // Deixa la dada a la memòria cau abans de pintar la ruta
  await client.ensureQueryData({
    queryKey: claus.estacions.detall(params.estacionId),
    queryFn: () => obtenirEstacio(params.estacionId)
  });
  return null;   // el component la llegirà amb useQuery
};

Errors Comuns i Consells

Error 1: que queryFn no llanci davant d'un error HTTP. fetch no llança amb un 404 ni un 500. Sense if (!resposta.ok) throw, Query guardarà el missatge d'error del servidor com si fos la dada.

Error 2: usar isFetching on toca isPending. La pantalla es buidarà a cada revalidació i hauràs convertit la millor característica de Query en un parpelleig.

Error 3: deixar fora de la clau un paràmetre que usa queryFn. Si estacioId no és a queryKey, canviar d'estació mostra les dades de l'anterior. Tot el que canviï el resultat va a la clau.

Error 4: crear el QueryClient dins d'un component. new QueryClient() al cos d'un component crea una memòria cau nova a cada render. Va en el seu propi mòdul, fora.

Error 5: copiar data a un useState o a Redux. Duplica la font de veritat i anul·la la revalidació: la teva còpia no se n'assabenta de res. Usa data directament.

Error 6: oblidar cancelQueries en una actualització optimista. Una revalidació en curs pot acabar després i trepitjar el teu canvi amb les dades velles. És una fallada intermitent i molt difícil de diagnosticar.

Error 7: mutar la memòria cau a setQueryData. L'actualitzador ha de retornar un objecte nou, igual que un reductor. Mutar l'objecte de la memòria cau trenca la comparació de referències i els components no se n'assabenten.

Error 8: invalidar massa. invalidateQueries() sense clau invalida tot i provoca una tempesta de peticions. Invalida només la branca afectada.

Consell 1: centralitza les claus en una fàbrica. Elimina les errades, fa explícita la jerarquia i converteix canviar el nom d'un recurs en canviar un fitxer.

Consell 2: ajusta staleTime per tipus de dada. El valor per defecte de 0 és conservador. Les estacions de CicloUrbano no canvien: una hora està bé i estalvia desenes de peticions.

Consell 3: usa les Query DevTools des del primer dia. Mostren cada consulta amb la seva clau, el seu estat —fresc, obsolet, inactiu—, quan es va demanar per última vegada i quines dades té. És l'equivalent de Redux DevTools per a aquesta capa.

Consell 4: encapsula cada consulta en un hook propi. useBicicletes, useEstacions, useReserves, useCrearReserva. Els components no haurien de veure queryKey ni queryFn, exactament pel mateix motiu pel qual no haurien de veure la forma de l'estat de Redux.

Consell 5: no esborris db.json del teu flux de treball. Tenir una API que respon de debò, que persisteix i que a vegades falla és molt més formatiu que un simulacre que sempre funciona.

Exercicis

Exercici 1. Escriu els hooks useEstacions() i useEstacio(estacioId) sobre l'API fictícia, amb claus jeràrquiques, un staleTime justificat per a cadascun i el tractament correcte d'errors. Després reescriu PaginaEstacions perquè els faci servir, distingint bé la primera càrrega d'una revalidació.

Exercici 2. Aquest hook de mutació té quatre problemes. Troba'ls i corregeix-lo.

export function useCancelarReserva() {
  const client = useQueryClient();

  return useMutation({
    mutationFn: (idReserva) =>
      fetch(`http://localhost:3001/reservas/${idReserva}`, {
        method: 'PATCH',
        body: JSON.stringify({ estado: 'cancelada' })
      }),

    onMutate: (idReserva) => {
      const previes = client.getQueryData(['reserves']);
      const actualitzades = previes.map((r) => {
        if (r.id === idReserva) r.estat = 'cancelada';
        return r;
      });
      client.setQueryData(['reserves'], actualitzades);
    },

    onSuccess: () => {
      client.invalidateQueries();
    }
  });
}

Exercici 3. Després d'aquest mòdul, sliceReserves ha quedat gairebé buit. Decideix què es queda a Redux, què passa a TanStack Query i què desapareix del tot, per a cadascun d'aquests elements, i justifica cada decisió: (a) l'array ids i l'objecte entitats; (b) estatCarrega i error; (c) estatEnviament; (d) la regla «només una reserva activa es pot confirmar»; (e) el createAsyncThunk enviarReserva; (f) el selector seleccionarResumReserves.

Solucions

Solució 1.

// src/consultes/useEstacions.js
import { useQuery } from '@tanstack/react-query';
import { claus } from './claus.js';

const BASE = 'http://localhost:3001';

async function demanarJson(url, signal) {
  const resposta = await fetch(url, { signal });
  if (!resposta.ok) throw new Error(`El servidor ha respost ${resposta.status}`);
  return resposta.json();
}

export function useEstacions() {
  return useQuery({
    queryKey: claus.estacions.totes(),
    queryFn: ({ signal }) => demanarJson(`${BASE}/estaciones`, signal),
    // Nom, barri i places no canvien en tota la sessió d'un usuari
    staleTime: 60 * 60_000
  });
}

export function useEstacio(estacioId) {
  return useQuery({
    queryKey: claus.estacions.detall(estacioId),
    queryFn: ({ signal }) => demanarJson(`${BASE}/estaciones/${estacioId}`, signal),
    enabled: Boolean(estacioId),   // sense id, no es llança la consulta
    staleTime: 60 * 60_000
  });
}
// src/funcionalitats/estacions/PaginaEstacions.jsx
function PaginaEstacions() {
  const { data: estacions, isPending, isError, error, isFetching } = useEstacions();
  const [barri, setBarri] = useState('todos');   // estat local d'interfície

  if (isPending) return <IndicadorDeCarrega missatge="Carregant estacions…" />;
  if (isError) return <Avis to="error" text={error.message} />;

  // Derivats: expressions, no estat (07-01)
  const visibles = estacions.filter((est) => barri === 'todos' || est.barri === barri);
  const totalPlaces = visibles.reduce((suma, est) => suma + est.places, 0);

  return (
    <section>
      <h1>Estacions</h1>
      {isFetching && <span aria-live="polite">Actualitzant…</span>}
      <p>{visibles.length} estacions · {totalPlaces} places</p>
      <ul>
        {visibles.map((estacio) => (
          <li key={estacio.id}><TargetaEstacio estacio={estacio} /></li>
        ))}
      </ul>
    </section>
  );
}

enabled: Boolean(estacioId) substitueix la guarda if (!estacioId) return; que a 05-06 calia escriure dins de l'efecte. I fixa't que aquest component resol l'Exercici 2 de 07-01: sense efectes de sincronització, sense derivats guardats i amb l'estat del servidor on li correspon.

Solució 2. Els quatre problemes:

  1. mutationFn no comprova resposta.ok i li falta la capçalera Content-Type. Sense la comprovació, un 500 es considera un èxit i la reversió no passa mai; sense la capçalera, json-server pot no interpretar el cos.
  2. onMutate muta els objectes de la memòria cau. r.estat = 'cancelada' modifica l'objecte original dins de map, així que la «foto» previes també queda alterada: revertir seria impossible encara que hi hagués onError.
  3. Falta cancelQueries i falta onError. No hi ha reversió, i una revalidació en curs pot trepitjar el canvi optimista.
  4. invalidateQueries() sense clau invalida totes les consultes de l'aplicació, provocant una recàrrega general innecessària. I hauria d'anar a onSettled, no a onSuccess, per resincronitzar també després d'una fallada.
export function useCancelarReserva() {
  const client = useQueryClient();

  return useMutation({
    mutationFn: async (idReserva) => {
      const resposta = await fetch(`http://localhost:3001/reservas/${idReserva}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },   // 1)
        body: JSON.stringify({ estado: 'cancelada' })
      });
      if (!resposta.ok) throw new Error('No s\'ha pogut cancel·lar la reserva.');   // 1)
      return resposta.json();
    },

    onMutate: async (idReserva) => {
      await client.cancelQueries({ queryKey: claus.reserves.totes() });   // 3)
      const reservesPrevies = client.getQueryData(claus.reserves.totes());

      client.setQueryData(claus.reserves.totes(), (previes = []) =>
        previes.map((reserva) =>                                            // 2) immutable
          reserva.id === idReserva ? { ...reserva, estado: 'cancelada' } : reserva
        )
      );

      return { reservesPrevies };
    },

    onError: (fallada, idReserva, context) => {                              // 3)
      if (context?.reservesPrevies) {
        client.setQueryData(claus.reserves.totes(), context.reservesPrevies);
      }
    },

    onSettled: () => {
      client.invalidateQueries({ queryKey: claus.reserves.totes() });     // 4)
      client.invalidateQueries({ queryKey: claus.bicicletes.totes() });   // la bici s'allibera
    }
  });
}

El problema 2 és el més instructiu: una actualització optimista escrita amb mutació és pitjor que no tenir-la, perquè destrueix silenciosament l'única còpia que permetia tornar enrere. La immutabilitat no és un caprici de Redux; és el que fa possible desfer.

Solució 3.

Element Destinació Justificació
(a) ids i entitats A Query; desapareixen de Redux Són la còpia local d'un recurs remot. Query ja guarda les reserves per clau i les manté sincronitzades. La normalització manual deixa de fer falta: la memòria cau de Query ja està indexada per clau, i per a l'accés per identificador n'hi ha prou amb ['reserves', id]
(b) estatCarrega i error Desapareixen Són isPending, isError i error de useQuery. Mantenir-los seria duplicar informació que Query ja deriva, i amb el risc que es contradiguin
(c) estatEnviament Desapareix És isPending de useMutation, ara a més per mutació en curs i no com una variable global compartida per tots els formularis
(d) «només una reserva activa es pot confirmar» Al servidor, i al client com a validació d'interfície És una regla de negoci, i una regla de negoci que només viu al client no protegeix res: és la mateixa lliçó de 06-05 sobre l'autorització. Al client es conserva per no oferir un botó que fallarà (reserva.estat === 'activa' && <button>), però qui la fa complir és el PATCH
(e) enviarReserva A useMutation És una escriptura remota. Com useCrearReserva, amb invalidateQueries de reserves i de bicicletes. Es guanya la invalidació, que el thunk no tenia
(f) seleccionarResumReserves Es queda com a funció pura, fora de Redux El càlcul continua sent útil i continua sent pur; el que canvia és d'on surten les dades. Passa a ser resumirReserves(reserves) a src/utilitats/, invocada sobre el data de useQuery i memoïtzada amb useMemo si el cost ho justifica (08-03). La lògica sobreviu; l'acoblament al magatzem, no

I el balanç final: de sliceReserves no en queda pràcticament res. Això no vol dir que les lliçons 07-03 a 07-05 hagin estat temps perdut. El model d'accions, reductors purs, estat normalitzat i selectors és el que es fa servir a Zustand, a Jotai, a useReducer i —literalment— al setQueryData que acabes d'escriure, que és un reductor amb un altre nom. El que s'ha après és a classificar abans de triar, i el millor resultat possible d'aquesta classificació és descobrir que necessitaves menys del que creies.

Conclusió

Les dades que vénen d'un servidor no són estat de la teva aplicació: no et pertanyen, caduquen soles, altres les canvien mentre les mires i arriben tard. Guardar-les en useState o en Redux no està malament per gust, està malament perquè et fa responsable d'una llista de catorze problemes —càrrega, error, cancel·lació, condicions de cursa, deduplicació, revalidació en tornar a la pestanya i en reconnectar, invalidació després d'escriure, reintents, memòria cau compartida, recol·lecció de memòria, paginació sense parpelleig, dades prèvies mentre es revalida, actualització optimista— multiplicada per cada recurs. Els quatre primers et van costar una lliçó sencera al mòdul 5; els deu restants són els que ningú escriu a mà.

Has muntat una API fictícia real amb json-server i un db.json amb les cinc bicicletes, les tres estacions, els dos usuaris i la reserva canònics de CicloUrbano, servida a http://localhost:3001 amb filtres, paginació, ordenació i escriptures que persisteixen. Sobre ella, TanStack Query v5: un QueryClient creat fora dels components i proveït a main.jsx, useQuery amb la seva queryKeyla identitat de la memòria cau, jeràrquica del general a l'específic i centralitzada en una fàbrica— i la seva queryFn que ha de llançar davant d'un error HTTP perquè fetch no ho fa. Saps distingir isPending d'isFetching, que és el que separa una pantalla que parpelleja d'una que se sent instantània, i coneixes el cicle de vida d'una dada a la memòria cau —fresca, obsoleta, inactiva, recol·lectada— governat per staleTime («com me'n refio») i gcTime («quant la guardo quan ningú la mira»), amb valors raonats per tipus de dada: una hora per a les estacions, trenta segons per al catàleg, zero per a la disponibilitat en directe. I les funcions que no s'escriuen a mà: reintents filtrats pel tipus d'error, revalidació en tornar a la pestanya i en reconnectar, i keepPreviousData per paginar sense buidar la llista.

Al costat de l'escriptura, useMutation amb invalidateQueries per prefix —la recompensa directa de les claus jeràrquiques— i l'actualització optimista completa: onMutate cancel·lant les revalidacions en curs, guardant la foto anterior i escrivint el canvi de forma immutable; onError restaurant aquesta foto; onSettled resincronitzant passi el que passi. Els tres passos són inseparables, i una actualització optimista escrita amb mutació és pitjor que no tenir-la. useFetchBicicletes s'ha convertit en useBicicletes: de quaranta-cinc línies a vint, amb sis classes senceres de fallada que deixen de ser possibles i set funcions noves que abans no existien.

L'arquitectura final de CicloUrbano queda repartida sense solapaments: TanStack Query per a bicicletes, estacions, reserves i usuaris; Redux per a sliceSessio i un sliceCataleg reduït a terme i ordre; context per al tema i els avisos; la URL per al filtre ?tipo=; i useState local per a modals, esborranys i seleccions. I la conclusió honesta que aquest mòdul ha anat preparant des de 07-01: quan l'estat del servidor és al seu lloc, l'estat de client que queda és molt menys del que semblava. A aquesta conclusió només s'hi arriba havent entès Redux, no evitant-lo.

Amb això es tanca el Mòdul 7. CicloUrbano ja sap on viu cada dada i per què, té un magatzem auditable amb historial d'accions, una memòria cau que se sincronitza sola amb el servidor i una separació neta entre el que és seu i el que és del backend. Fa molt, i ho fa bé. El que encara no fa és anar ràpida: hi ha components que es repinten sense necessitat, selectors i càlculs que es refan a cada render, llistes que es tornen a pintar senceres perquè una prop ha canviat d'identitat, i un paquet final que el navegador descarrega sencer abans de mostrar la primera pantalla. El Mòdul 8: Optimització del Rendiment ataca precisament això: com identificar quin renderitzat sobra de debò abans de tocar res, React.memo per evitar repintats de components, useMemo i useCallback —el deute que aquest mòdul ha anat deixant a 07-02 i 07-05— per estabilitzar valors i funcions, la divisió de codi i la càrrega mandrosa perquè cada pantalla descarregui només el que li toca, i el React DevTools Profiler per mesurar en lloc de suposar. La propera lliçó és Tècniques d'Optimització del Rendiment a React.

Curs de React

Mòdul 1: Introducció a React

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats