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
- Per què les dades remotes no són estat de l'aplicació
- Tot el que cal resoldre a mà
- La API fictícia:
json-serveridb.json - TanStack Query v5: instal·lació i muntatge
useQuery: la consulta i el que retorna- Claus de consulta: la identitat de la memòria cau
- Cicle de vida d'una dada a la memòria cau
- Reintents, revalidació en focus i paginació
- Mutacions amb
useMutationi invalidació - Actualització optimista i la seva reversió
- De
useFetchBicicletesauseBicicletes - Com conviu amb Redux: l'arquitectura final
- Alternatives: RTK Query, SWR i els
loaderde React Router
- 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-002està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
useStateresponen «quin és el valor?». Una memòria cau d'estat de servidor respon a més «continua sent cert?».
- 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 | Sí |
| 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.
- La API fictícia:
json-server i db.json
json-server i db.jsonAbans 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.
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:
I afegeix la drecera a package.json:
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.
- TanStack Query v5: instal·lació i muntatge
I, opcionalment però molt recomanable, les eines de desenvolupament:
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.
useQuery: la consulta i el que retorna
useQuery: la consulta i el que retornaimport { 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.
- 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 usuariRegles del disseny de claus:
- Del general a l'específic, com una ruta. És el que permet invalidar per prefix (apartat 9).
- Tot el que canviï el resultat va a la clau. Si
queryFnusaestacioId, aquest identificador ha d'estar a la clau; si no, canviar d'estació mostraria les dades de l'anterior. - 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. - 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.
- 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.
- 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.
- Mutacions amb
useMutation i invalidació
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 descendentsAquí 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.
- 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.
- De
useFetchBicicletes a useBicicletes
useFetchBicicletes a useBicicletesToca 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:
- Navegar ràpid entre dues estacions i veure la flota de l'estació equivocada.
- Obrir dos panells que mostren les mateixes bicicletes i fer dues peticions idèntiques.
- Tornar a una pantalla i esperar de nou que carreguin dades que ja tenies.
- Crear una reserva i que el catàleg continuï mostrant la bicicleta com a disponible.
- Perdre la connexió, recuperar-la i quedar-te amb dades de fa deu minuts.
- 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.
- 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.
- Alternatives: RTK Query, SWR i els
loader de React Router
loader de React RouterTanStack 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 | Sí | No | No |
| Memòria cau per clau | Sí | Sí, generada de l'endpoint | Sí | 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 |
Sí, 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 | Sí: 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
loaderde React Router (06-03): resolen un problema diferent i complementari. Unloadercarrega 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: elloaderprecarrega la consulta alqueryClienti el component la llegeix ambuseQuery, 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:
mutationFnno comprovaresposta.oki li falta la capçaleraContent-Type. Sense la comprovació, un 500 es considera un èxit i la reversió no passa mai; sense la capçalera,json-serverpot no interpretar el cos.onMutatemuta els objectes de la memòria cau.r.estat = 'cancelada'modifica l'objecte original dins demap, així que la «foto»previestambé queda alterada: revertir seria impossible encara que hi haguésonError.- Falta
cancelQueriesi faltaonError. No hi ha reversió, i una revalidació en curs pot trepitjar el canvi optimista. invalidateQueries()sense clau invalida totes les consultes de l'aplicació, provocant una recàrrega general innecessària. I hauria d'anar aonSettled, no aonSuccess, 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 queryKey —la 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
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
