La interfície està acabada i no fa res. Les bicicletes surten d'un fitxer, el filtre s'oblida en recarregar, el formulari no envia, la sessió no existeix i RutaProtegida deixa passar qualsevol. Aquesta lliçó connecta els cables: en acabar, CicloUrbano serà una aplicació de veritat, amb dades que viatgen per la xarxa, es guarden en memòria cau, s'invaliden i es mostren amb els seus estats de càrrega i d'error al lloc correcte.
La feina s'organitza de dins cap a fora. Primer una capa d'accés a dades que no sap res de React i que centralitza la URL base, les capçaleres, la gestió d'errors HTTP i el temps d'espera. A sobre, TanStack Query amb les seves claus jeràrquiques, els seus valors per defecte triats amb criteri i les mutacions del projecte —incloent-hi l'actualització optimista amb la seva reversió. Al costat, Redux Toolkit per al que sí que és estat de client, context per a tema i avisos, i la URL per al filtre. I per damunt de tot, la pregunta que decideix la qualitat percebuda d'una aplicació: quan alguna cosa falla, qui mostra l'error.
Res del que ve a continuació és nou conceptualment; tot es va explicar al mòdul 7. El que és nou és que aquí s'aplica sobre un projecte complet, on les decisions es toquen entre elles i cal resoldre els conflictes.
Contingut
- La taula definitiva: quina dada viu on
- La capa d'accés a dades:
src/api/client.js - Funcions per recurs
- Per què aquesta capa no sap res de React
clientConsultes.jsi els seus valors per defecte- La fàbrica de claus
- Els hooks de consulta del projecte
- Connectar el catàleg: dels interruptors als estats reals
- La mutació completa: crear una reserva
- Actualització optimista: confirmar i cancel·lar
- Redux: la sessió
- Redux: el catàleg, i què no entra a Redux
- Context: tema i avisos
- El filtre a la URL amb
useSearchParams - Rutes protegides connectades a la sessió real
- Gestió d'errors de cap a cap
main.jsxdefinitiu- El flux complet d'una reserva
- Comprovació manual del recorregut
- La taula definitiva: quina dada viu on
L'acta d'11-01 va fixar el criteri; això n'és l'aplicació dada per dada. És la taula que es consulta cada vegada que apareix un estat nou, i la que evita la discussió de sempre.
| Dada | On viu | Per què allà i no en un altre lloc |
|---|---|---|
| Llista de bicicletes | TanStack Query | Viu al servidor; necessita memòria cau, revalidació i invalidació després de mutar |
| Fitxa d'una bicicleta | TanStack Query | Ídem, amb clau de detall pròpia |
| Estacions i el seu detall | TanStack Query | Ídem. Canvien poquíssim: staleTime alt |
| Reserves de l'usuari | TanStack Query | Ídem, amb clau dependent de l'usuari |
| Usuari identificat | Redux (sliceSessio) |
El llegeixen la capçalera, les rutes protegides i el formulari de reserva. No és una còpia d'un recurs remot: és l'estat d'aquesta sessió |
| Terme de cerca | Redux (sliceCataleg) |
L'escriu el cercador i el llegeix la llista; sobreviu a navegar a una fitxa i tornar |
| Criteri d'ordre | Redux (sliceCataleg) |
Ídem, i és una preferència de l'usuari dins de la sessió |
| Filtre per tipus | URL (?tipo=) |
Ha de ser compartible per enllaç i sobreviure a la recàrrega (H2) |
| Pestanya activa d'estació | URL (segment de ruta) | Ídem |
| Tema clar/fosc | Context + localStorage |
Canvia poc, el necessita tot l'arbre |
| Avisos temporals | Context | Els emet qualsevol pantalla i els pinta el marc |
| Modal de confirmació obert | useState local |
Ningú fora de la pantalla ho necessita saber |
| Esborrany del formulari | useState local |
Es descarta en sortir; desar-lo a Redux només afegiria accions |
| Estat d'enviament d'una mutació | TanStack Query (isPending) |
El dona la mutació; duplicar-lo en useState produeix desincronització |
Les dues files en què més s'erra en projectes reals són la primera i l'última. Ficar la llista de bicicletes a Redux obliga a reimplementar memòria cau, deduplicació i revalidació (07-06). Duplicar isPending en un useState produeix el clàssic botó que es queda girant per sempre perquè algú va oblidar posar-lo a false a la branca d'error.
flowchart TD
subgraph SERVIDOR["Estat del servidor · TanStack Query"]
Q1["bicicletes"]
Q2["estacions"]
Q3["reserves"]
end
subgraph CLIENTE["Estat de client · Redux Toolkit"]
R1["sliceSessio: usuari"]
R2["sliceCataleg: terme, ordre"]
end
subgraph URL["Estat d'URL · React Router"]
U1["?tipo=electrica"]
U2["/estaciones/est-01/incidencias"]
end
subgraph CONTEXTO["Interfície global · Context"]
C1["tema"]
C2["avisos"]
end
subgraph LOCAL["Local · useState"]
L1["modal obert"]
L2["esborrany del formulari"]
end
PANTALLA["Una pantalla"] --> SERVIDOR
PANTALLA --> CLIENTE
PANTALLA --> URL
PANTALLA --> CONTEXTO
PANTALLA --> LOCAL
- La capa d'accés a dades:
src/api/client.js
src/api/client.jsAbans d'escriure un sol hook, cal decidir com es parla amb la xarxa. Repartir fetch pels components significa repetir la URL base, el Content-Type, la comprovació de resposta.ok i el JSON.parse en quinze llocs, i descobrir el dia del desplegament que un d'ells es va oblidar de comprovar l'estat.
// src/api/client.js
import { URL_API } from '../configuracio.js';
const TEMPS_ESPERA = 10_000; // 10 s: més enllà, la xarxa es dona per perduda
/**
* Error de la capa de dades. Porta el codi HTTP perquè qui el rebi
* pugui decidir: 404 no és el mateix que 500 ni que una fallada de xarxa.
*/
export class ErrorApi extends Error {
constructor(missatge, { estat = null, url = null, cos = null } = {}) {
super(missatge);
this.name = 'ErrorApi';
this.estat = estat;
this.url = url;
this.cos = cos;
}
get esNoTrobat() {
return this.estat === 404;
}
get esDeXarxa() {
return this.estat === null; // mai hi va haver resposta
}
get esDelServidor() {
return this.estat !== null && this.estat >= 500;
}
}
/**
* Embolcall únic de fetch. Totes les peticions del projecte hi passen.
*/
export async function peticio(ruta, opcions = {}) {
const { metode = 'GET', cos, senyal, ...resta } = opcions;
// Temporitzador propi: fetch no el porta, i una petició penjada
// deixa la interfície en "carregant" per sempre
const controlador = new AbortController();
const temporitzador = setTimeout(() => controlador.abort(), TEMPS_ESPERA);
// Si qui truca porta la seva pròpia senyal (TanStack Query la passa), es combinen
const senyalFinal = senyal
? AbortSignal.any([senyal, controlador.signal])
: controlador.signal;
const url = `${URL_API}${ruta}`;
try {
const resposta = await fetch(url, {
method: metode,
signal: senyalFinal,
headers: {
Accept: 'application/json',
...(cos ? { 'Content-Type': 'application/json' } : {}),
...resta.headers
},
...(cos ? { body: JSON.stringify(cos) } : {}),
...resta
});
if (!resposta.ok) {
// S'intenta llegir el cos de l'error, però la seva absència no ha de trencar res
let detall = null;
try {
detall = await resposta.json();
} catch {
detall = null;
}
throw new ErrorApi(missatgePerEstat(resposta.status), {
estat: resposta.status,
url,
cos: detall
});
}
// 204 No Content: no hi ha cos a interpretar
if (resposta.status === 204) return null;
return await resposta.json();
} catch (error) {
if (error instanceof ErrorApi) throw error;
if (error.name === 'AbortError') {
throw new ErrorApi('La petició ha trigat massa.', { url });
}
// TypeError de fetch = no hi va haver resposta: sense xarxa, DNS, CORS…
throw new ErrorApi('No s\'ha pogut connectar amb el servidor.', { url });
} finally {
clearTimeout(temporitzador);
}
}
function missatgePerEstat(estat) {
if (estat === 404) return 'El recurs sol·licitat no existeix.';
if (estat === 401) return 'La sessió ha caducat.';
if (estat === 403) return 'No tens permís per a aquesta operació.';
if (estat >= 500) return 'El servidor no està responent correctament.';
return 'La petició no s\'ha pogut completar.';
}El que resol cada part, perquè cadascuna ve d'una fallada real:
| Part | Problema que evita |
|---|---|
URL_API des de configuracio.js |
Canviar d'entorn sense tocar quinze fitxers |
ErrorApi amb estat |
Poder distingir 404 de 500 d'una fallada de xarxa a dalt, a la interfície |
AbortController amb temporitzador |
La petició penjada que deixa l'esquelet girant indefinidament |
AbortSignal.any |
Combinar el temps d'espera amb la cancel·lació que envia TanStack Query en desmuntar |
Comprovació de resposta.ok |
fetch no llança amb un 500: sense això, un error del servidor arribaria com a dada vàlida |
try/catch en llegir el cos de l'error |
Una resposta d'error sense JSON no ha de produir un segon error més confús que el primer |
Cas 204 |
resposta.json() sobre un cos buit llança |
missatgePerEstat |
Missatges en català, comprensibles, en un sol lloc |
finally amb clearTimeout |
Un temporitzador que sobreviu a la petició |
- Funcions per recurs
Per damunt de l'embolcall, una funció per operació. Són les úniques que coneixen les rutes de l'API.
// src/api/bicicletes.js
import { peticio } from './client.js';
export function obtenirBicicletes({ tipus, senyal } = {}) {
// json-server filtra per camp amb un paràmetre de consulta
const parametres = new URLSearchParams();
if (tipus && tipus !== 'todos') parametres.set('tipus', tipus);
const consulta = parametres.toString();
return peticio(`/bicicletas${consulta ? `?${consulta}` : ''}`, { senyal });
}
export function obtenirBicicleta(id, { senyal } = {}) {
return peticio(`/bicicletas/${id}`, { senyal });
}
export function actualitzarEstatBicicleta(id, estat) {
return peticio(`/bicicletas/${id}`, { metode: 'PATCH', cos: { estat } });
}// src/api/reserves.js
import { peticio } from './client.js';
export function obtenirReserves({ usuariId, senyal } = {}) {
const ruta = usuariId ? `/reservas?usuari=${usuariId}` : '/reservas';
return peticio(ruta, { senyal });
}
export function crearReserva(dades) {
return peticio('/reservas', { metode: 'POST', cos: dades });
}
export function actualitzarReserva(id, canvis) {
return peticio(`/reservas/${id}`, { metode: 'PATCH', cos: canvis });
}// src/api/estacions.js
import { peticio } from './client.js';
export function obtenirEstacions({ senyal } = {}) {
return peticio('/estaciones', { senyal });
}
export function obtenirEstacio(id, { senyal } = {}) {
return peticio(`/estaciones/${id}`, { senyal });
}// src/api/usuaris.js
import { peticio } from './client.js';
export async function cercarUsuariPerEmail(email) {
// json-server retorna un array en filtrar; aquí es normalitza a un objecte o null
const trobats = await peticio(`/usuarios?email=${encodeURIComponent(email)}`);
return trobats[0] ?? null;
}Aquest encodeURIComponent no és paranoia: un correu amb un + —perfectament vàlid i bastant habitual— s'interpretaria com un espai a la cadena de consulta i la cerca no trobaria res. És una fallada que només apareix amb certs usuaris, que és la pitjor mena de fallada.
- Per què aquesta capa no sap res de React
Ni un import de React, ni un hook, ni una referència a l'estat. És una decisió d'arquitectura amb cinc conseqüències mesurables:
| Benefici | A la pràctica |
|---|---|
| Es prova sense muntar res | await obtenirBicicletes() amb MSW interceptant, sense render ni proveïdors |
| Es pot reutilitzar fora de React | Un script de migració, una prova de Cypress, una futura aplicació mòbil (10-05) |
| Canviar de biblioteca de dades no l'afecta | Si demà se substitueix TanStack Query, aquesta capa no es toca |
| Un únic punt de canvi | L'autenticació real d'11-05 s'afegeix a peticio, i arriba a totes les crides |
| Frontera clara a la revisió de codi | Un useState dins de src/api/ és un error evident, no una discussió d'estil |
La regla que ho resumeix: src/api/ parla HTTP; src/consultes/ parla React. Si una funció necessita saber si un component està muntat, és a la carpeta equivocada.
clientConsultes.js i els seus valors per defecte
clientConsultes.js i els seus valors per defecte// src/consultes/clientConsultes.js
import { QueryClient } from '@tanstack/react-query';
import { ErrorApi } from '../api/client.js';
export const clientConsultes = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: true,
retry: (intents, error) => {
// Un 404 no millora reintentant: és una resposta correcta a una pregunta mal feta
if (error instanceof ErrorApi && error.estat >= 400 && error.estat < 500) {
return false;
}
return intents < 2;
}
},
mutations: {
retry: false // reintentar un POST pot crear dues reserves
}
}
});Cada valor, amb la seva justificació per a aquest projecte:
| Opció | Valor | Per què |
|---|---|---|
staleTime |
30 s | L'estat de les bicicletes canvia amb l'ús real de la xarxa, però no cada segon. Mig minut evita una tempesta de peticions en navegar entre pantalles i manté la dada raonablement fresca |
gcTime |
5 min | Tornar a una pantalla visitada fa poc és instantani, i la memòria no creix sense control |
refetchOnWindowFocus |
true |
Algú que deixa la pestanya oberta vint minuts i torna ha de veure l'estat actual, no el d'abans de dinar |
retry de consultes |
Funció | Reintentar un 4xx és temps perdut: la resposta no canviarà. Els 5xx i les fallades de xarxa sí que es reintenten dues vegades |
retry de mutacions |
false |
Un POST /reservas reintentat després d'un temps d'espera exhaurit pot crear dues reserves. L'API no és idempotent i no hi ha clau d'idempotència |
L'última fila és la més important i la que més s'ignora. Si la petició va arribar al servidor i la resposta es va perdre pel camí, el reintent crea un duplicat. Amb reserves, això és diners. Davant el dubte, una mutació no es reintenta sola: se li ofereix a la persona el botó de tornar-ho a intentar.
I un ajustament fi per recurs, allà on el valor global no encaixa:
// Les estacions canvien de mes en mes, no de minut en minut
export function useEstacions() {
return useQuery({
queryKey: claus.estacions.totes(),
queryFn: ({ signal }) => obtenirEstacions({ senyal: signal }),
staleTime: 10 * 60_000 // 10 minuts: sobreescriu el global
});
}
- La fàbrica de claus
// 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 }]
}
};La jerarquia és el que fa que la invalidació sigui precisa sense ser tediosa:
| Invalidar | Afecta | No afecta |
|---|---|---|
['bicicletes'] |
Totes les llistes i tots els detalls | Estacions i reserves |
['bicicletes', { tipus: 'urbana' }] |
Només aquesta llista filtrada | Les altres llistes |
['bicicletes', 'detall', 'bici-001'] |
Només aquesta fitxa | La llista |
['reserves'] |
Les de tots els usuaris | Bicicletes |
TanStack Query compara les claus per prefix, de manera que invalidar el pare arriba a tots els fills. És exactament el comportament que es vol després de crear una reserva: canvia la llista, canvia la fitxa d'aquesta bicicleta i canvia la llista de reserves, i amb dues línies queden les tres marcades.
- Els hooks de consulta del projecte
// src/consultes/bicicletes.js
import { useQuery } from '@tanstack/react-query';
import { obtenirBicicletes, obtenirBicicleta } from '../api/bicicletes.js';
import { claus } from './claus.js';
export function useBicicletes(filtres = {}) {
return useQuery({
queryKey: claus.bicicletes.llista(filtres),
// signal l'aporta Query: cancel·la la petició si el component es desmunta
queryFn: ({ signal }) => obtenirBicicletes({ ...filtres, senyal: signal })
});
}
export function useBicicleta(id) {
return useQuery({
queryKey: claus.bicicletes.detall(id),
queryFn: ({ signal }) => obtenirBicicleta(id, { senyal: signal }),
enabled: Boolean(id) // sense id no es llança la petició
});
}// src/consultes/reserves.js
import { useQuery } from '@tanstack/react-query';
import { obtenirReserves } from '../api/reserves.js';
import { claus } from './claus.js';
export function useReserves(usuariId) {
return useQuery({
queryKey: claus.reserves.deUsuari(usuariId),
queryFn: ({ signal }) => obtenirReserves({ usuariId, senyal: signal }),
enabled: Boolean(usuariId) // sense sessió no hi ha reserves a demanar
});
}L'enabled es mereix una nota. Sense ell, en entrar a /reservas sense sessió es llançaria GET /reservas?usuari=undefined, que a json-server retorna una llista buida i en una API real retornaria un 400. Amb enabled: false, la consulta queda en estat pending sense demanar res, i arrenca sola tan bon punt usuariId deixa de ser nul. És la forma correcta d'expressar «això depèn d'alguna cosa que encara no tinc».
- Connectar el catàleg: dels interruptors als estats reals
Aquí es cobra la feina d'11-02. Els interruptors CARREGANT i AMB_ERROR desapareixen i el seu marcatge es queda tal qual.
// src/pagines/PaginaCataleg.jsx
import { useMemo, useDeferredValue } from 'react';
import { useSearchParams } from 'react-router';
import { useSelector, useDispatch } from 'react-redux';
import { useBicicletes } from '../consultes/bicicletes.js';
import { useEstacions } from '../consultes/estacions.js';
import { seleccionarTerme, seleccionarOrdre, termeCanviat }
from '../funcionalitats/cataleg/sliceCataleg.js';
import Panell from '../components/base/Panell.jsx';
import SelectorTipus from '../components/SelectorTipus.jsx';
import CercadorBicicletes from '../components/CercadorBicicletes.jsx';
import LlistaBicicletes from '../components/LlistaBicicletes.jsx';
import ResumFlota from '../components/ResumFlota.jsx';
import EsqueletPagina from '../components/EsqueletPagina.jsx';
import Avis from '../components/Avis.jsx';
import Boto from '../components/base/Boto.jsx';
import estils from './PaginaCataleg.module.css';
function PaginaCataleg() {
// 1) El filtre viu a la URL
const [parametres, setParametres] = useSearchParams();
const tipus = parametres.get('tipo') ?? 'todos';
// 2) El terme i l'ordre viuen a Redux
const terme = useSelector(seleccionarTerme);
const ordre = useSelector(seleccionarOrdre);
const despatxar = useDispatch();
// 3) Les dades viuen al servidor
const consulta = useBicicletes({ tipus });
const { data: estacions = [] } = useEstacions();
const termeDiferit = useDeferredValue(terme);
const visibles = useMemo(() => {
const text = termeDiferit.trim().toLowerCase();
const llista = (consulta.data ?? []).filter((bici) =>
bici.model.toLowerCase().includes(text)
);
return [...llista].sort((a, b) =>
ordre === 'preu' ? a.preuHora - b.preuHora : a.model.localeCompare(b.model)
);
}, [consulta.data, termeDiferit, ordre]);
function gestionarCanviTipus(tipusNou) {
// replace: true evita omplir l'historial amb cada clic de filtre
if (tipusNou === 'todos') {
setParametres({}, { replace: true });
} else {
setParametres({ tipo: tipusNou }, { replace: true });
}
}
if (consulta.isPending) return <EsqueletPagina files={5} />;
if (consulta.isError) {
return (
<Avis to="error" titol="No s'han pogut carregar les bicicletes">
<p>{consulta.error.missatgeAmigable ?? consulta.error.message}</p>
<Boto onClick={() => consulta.refetch()}>Reintentar</Boto>
</Avis>
);
}
return (
<>
<h1 className={estils.titol}>Catàleg de bicicletes</h1>
<p className={estils.entradeta} aria-live="polite">
{visibles.length} de {consulta.data.length} bicicletes
{consulta.isFetching && <span className={estils.actualitzant}> · actualitzant…</span>}
</p>
<ResumFlota bicicletes={consulta.data} />
<Panell titol="Filtres" nivell={2} className={estils.filtres}>
<SelectorTipus tipusEscollit={tipus} alCanviarTipus={gestionarCanviTipus} />
<CercadorBicicletes
terme={terme}
alCercar={(valor) => despatxar(termeCanviat(valor))}
/>
</Panell>
{visibles.length === 0 ? (
<div className={estils.buit}>
<h2>Cap bicicleta coincideix amb la cerca</h2>
<p>Prova amb un altre tipus o esborra el text del cercador.</p>
</div>
) : (
<LlistaBicicletes bicicletes={visibles} estacions={estacions} />
)}
</>
);
}
export default PaginaCataleg;Les quatre decisions que defineixen aquesta pantalla:
- El filtre per tipus va al servidor; la cerca per text, no. El tipus forma part de la clau de consulta, així que cada tipus es guarda en memòria cau per separat i tornar a «Elèctrica» és instantani. El text, en canvi, canvia amb cada tecla: enviar-lo al servidor seria una petició per polsació. Es filtra al client sobre dades ja carregades.
isPendingenfront deisFetching.isPendingés «encara no hi ha dada» i pinta l'esquelet.isFetchingés «hi ha dada i a més s'està refrescant», i se senyala amb un text discret: substituir la llista per un esquelet a cada revalidació seria un parpelleig constant i injustificat.replace: trueen filtrar. Sense això, triar quatre filtres seguits obliga a prémer enrere quatre vegades per sortir de la pantalla. El filtre ha de ser enllaçable, però no cada pas intermedi ha de ser una entrada de l'historial.aria-live="polite"al recompte. En filtrar, qui no veu la pantalla necessita assabentar-se que el nombre de resultats ha canviat. És una línia i resol un problema real.
- La mutació completa: crear una reserva
L'operació central del producte, amb els seus sis passos.
// src/consultes/reserves.js — continuació
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { crearReserva } from '../api/reserves.js';
import { claus } from './claus.js';
export function useCrearReserva() {
const client = useQueryClient();
return useMutation({
mutationFn: crearReserva,
onSuccess: (reservaCreada) => {
// La llista de reserves de l'usuari ha canviat
client.invalidateQueries({
queryKey: claus.reserves.deUsuari(reservaCreada.usuari)
});
// La bicicleta passa a estar compromesa: el catàleg sencer pot haver canviat
client.invalidateQueries({ queryKey: claus.bicicletes.totes() });
}
});
}// src/pagines/PaginaNovaReserva.jsx
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useSelector } from 'react-redux';
import { useBicicletes } from '../consultes/bicicletes.js';
import { useCrearReserva } from '../consultes/reserves.js';
import { seleccionarUsuari } from '../funcionalitats/sessio/sliceSessio.js';
import { useAvisos } from '../contextos/avisos.js';
import { validarReserva } from '../utilitats/validarReserva.js';
import FormulariReserva from '../components/FormulariReserva.jsx';
import EsqueletPagina from '../components/EsqueletPagina.jsx';
import Avis from '../components/Avis.jsx';
function PaginaNovaReserva() {
const navegar = useNavigate();
const [parametres] = useSearchParams();
const usuari = useSelector(seleccionarUsuari);
const { afegirAvis } = useAvisos();
const consultaBicicletes = useBicicletes();
const mutacio = useCrearReserva();
const [errors, setErrors] = useState({});
const valorInicial = {
// Preselecció des de la fitxa: /reservas/nueva?bicicleta=bici-001
bicicletaId: parametres.get('bicicleta') ?? '',
dataInici: '',
hores: 1,
condicions: false
};
function gestionarEnviar(dades) {
// 1) Validar contra les dades REALS, no contra una còpia
const errorsTrobats = validarReserva(dades, consultaBicicletes.data ?? []);
setErrors(errorsTrobats);
if (Object.keys(errorsTrobats).length > 0) return;
// 2) Enviar
mutacio.mutate(
{
id: `res-${Date.now()}`, // json-server accepta l'id; una API real el generaria
bicicletaId: dades.bicicletaId,
usuari: usuari.id,
dataInici: dades.dataInici,
hores: Number(dades.hores),
estat: 'activa'
},
{
// 3) Èxit: avís i redirecció
onSuccess: () => {
afegirAvis({ to: 'exit', text: 'Reserva creada correctament.' });
navegar('/reservas', { replace: true });
},
// 4) Error: avís en línia, sense sortir de la pantalla
onError: (error) => {
afegirAvis({
to: 'error',
text: `No s'ha pogut crear la reserva. ${error.message}`
});
}
}
);
}
if (consultaBicicletes.isPending) return <EsqueletPagina files={4} />;
if (consultaBicicletes.isError) {
return (
<Avis to="error" titol="No s'ha pogut carregar el catàleg">
Sense la llista de bicicletes no es pot crear una reserva. Torna-ho a provar.
</Avis>
);
}
return (
<>
<h1>Nova reserva</h1>
<FormulariReserva
bicicletes={consultaBicicletes.data}
valorInicial={valorInicial}
errors={errors}
enviant={mutacio.isPending}
alEnviar={gestionarEnviar}
/>
</>
);
}
export default PaginaNovaReserva;Els sis passos i la raó de cadascun:
| Pas | On | Per què així |
|---|---|---|
| 1. Validar | A la pàgina, abans de mutate |
validarReserva necessita la llista real de bicicletes per comprovar que l'escollida està disponible. Validar contra dades de fa cinc minuts permetria reservar una bicicleta ja llogada |
| 2. Enviar | mutacio.mutate |
isPending desactiva el botó: la protecció contra el doble enviament no és un useState propi |
| 3. Invalidar | onSuccess del hook |
Va al hook, no a la pàgina: és una conseqüència del domini, no d'aquesta pantalla. Si una altra pantalla crea reserves, la invalidació també passa |
| 4. Avisar | onSuccess de la crida |
Va a la pàgina: l'avís i la redirecció són decisions d'aquesta pantalla concreta |
| 5. Redirigir | navegar('/reservas', { replace: true }) |
replace evita que el botó enrere torni al formulari ja enviat, amb el risc d'un segon enviament (06-04) |
| 6. Gestionar l'error | onError de la crida |
La fallada d'una mutació no ha de treure de la pantalla: les dades escrites hi continuen i es pot reintentar |
La distinció entre l'onSuccess del hook i el de la crida és subtil i molt útil: el del hook expressa el que sempre és cert (les dades afectades queden obsoletes), el de la crida expressa el que vol aquesta pantalla (avisar i navegar). Tots dos s'executen, primer el del hook.
- Actualització optimista: confirmar i cancel·lar
Cancel·lar una reserva és una operació en què esperar mig segon que respongui el servidor es nota. L'actualització optimista pinta el resultat abans de tenir-lo, i el desfà si falla.
// src/consultes/reserves.js — continuació
export function useCancellarReserva(usuariId) {
const client = useQueryClient();
const clau = claus.reserves.deUsuari(usuariId);
return useMutation({
mutationFn: (idReserva) => actualitzarReserva(idReserva, { estat: 'cancelada' }),
onMutate: async (idReserva) => {
// 1) Aturar les consultes en curs: si una arriba després, trepitjaria el canvi
await client.cancelQueries({ queryKey: clau });
// 2) Desar la foto actual per poder revertir
const anterior = client.getQueryData(clau);
// 3) Pintar el resultat ja
client.setQueryData(clau, (reserves = []) =>
reserves.map((reserva) =>
reserva.id === idReserva ? { ...reserva, estat: 'cancelada' } : reserva
)
);
// El que es retorna arriba a onError i onSettled com a "context"
return { anterior };
},
onError: (error, idReserva, context) => {
// 4) Revertir a l'estat exacte d'abans
if (context?.anterior) {
client.setQueryData(clau, context.anterior);
}
},
onSettled: () => {
// 5) Passi el que passi, sincronitzar amb el servidor
client.invalidateQueries({ queryKey: clau });
client.invalidateQueries({ queryKey: claus.bicicletes.totes() });
}
});
}sequenceDiagram
participant U as Usuari
participant C as Component
participant Q as Memòria cau de Query
participant S as Servidor
U->>C: Prem "Sí, cancel·la"
C->>Q: mutate(idReserva)
Q->>Q: onMutate: cancelQueries + foto + setQueryData
Q-->>C: La fila ja mostra "Cancelada"
Q->>S: PATCH /reservas/res-01
alt Resposta correcta
S-->>Q: 200 OK
Q->>Q: onSettled: invalidateQueries
Q->>S: GET /reservas (confirmació)
else Error
S-->>Q: 500
Q->>Q: onError: setQueryData(foto)
Q-->>C: La fila torna a "Activa"
C-->>U: Avís "No s'ha pogut cancel·lar"
end
Els tres passos que no es poden saltar, amb la conseqüència exacta d'ometre'n cadascun:
| Pas | Si s'omet |
|---|---|
cancelQueries |
Una consulta llançada abans de la mutació arriba després i restaura l'estat antic. La fallada intermitent perfecta: passa una vegada de cada deu |
Desar anterior |
No hi ha a què revertir. La interfície es queda mentint fins a la següent recàrrega |
onSettled amb invalidateQueries |
La memòria cau conserva el valor endevinat, no el real. Si el servidor va desar alguna cosa diferent —una marca de temps, un estat derivat—, mai te n'assabentes |
I la pregunta de fons: quan val la pena?
| Operació | Optimista? | Motiu |
|---|---|---|
| Cancel·lar una reserva | Sí | Canvi d'un camp, resultat predictible, fallada rara i reversible |
| Confirmar una reserva | Sí | Ídem |
| Canviar l'estat d'una bicicleta (taller) | Sí | Ídem, i l'operari en fa moltes de seguides |
| Crear una reserva | No | El servidor assigna l'identificador; endevinar-lo obliga a reconciliar després. I si falla, cal retirar de la llista alguna cosa que la persona ja ha vist creada |
| Qualsevol operació amb pagament | No | Mai es mostra com a fet alguna cosa que implica diners i no està confirmada |
- Redux: la sessió
// src/funcionalitats/sessio/sliceSessio.js
import { createSlice } from '@reduxjs/toolkit';
const CLAU_MAGATZEM = 'ciclourbano:sesion';
function llegirSessioDesada() {
try {
const desat = localStorage.getItem(CLAU_MAGATZEM);
return desat ? JSON.parse(desat) : null;
} catch {
// JSON corrupte o emmagatzematge bloquejat: es comença sense sessió
return null;
}
}
const estatInicial = {
usuari: llegirSessioDesada(),
carregant: false,
error: null
};
const sliceSessio = createSlice({
name: 'sessio',
initialState: estatInicial,
reducers: {
accesIniciat(estat) {
estat.carregant = true;
estat.error = null;
},
sessioIniciada(estat, accio) {
estat.usuari = accio.payload;
estat.carregant = false;
estat.error = null;
},
accesFallit(estat, accio) {
estat.usuari = null;
estat.carregant = false;
estat.error = accio.payload;
},
sessioTancada(estat) {
estat.usuari = null;
estat.error = null;
}
}
});
export const { accesIniciat, sessioIniciada, accesFallit, sessioTancada } =
sliceSessio.actions;
// Selectors: el slice és l'únic que coneix la forma de l'estat
export const seleccionarUsuari = (estat) => estat.sessio.usuari;
export const seleccionarEstaIdentificat = (estat) => estat.sessio.usuari !== null;
export const seleccionarEsOperari = (estat) => estat.sessio.usuari?.rol === 'operario';
export const seleccionarCarregantSessio = (estat) => estat.sessio.carregant;
export const seleccionarErrorSessio = (estat) => estat.sessio.error;
export default sliceSessio.reducer;La persistència es resol amb un middleware, no repetint localStorage.setItem a cada reductor:
// src/magatzem/persistenciaSessio.js
const CLAU_MAGATZEM = 'ciclourbano:sesion';
export const persistenciaSessio = (magatzem) => (seguent) => (accio) => {
const resultat = seguent(accio);
// Només reacciona a les accions de sessió: no escriu a cada tecla del cercador
if (accio.type.startsWith('sessio/')) {
const usuari = magatzem.getState().sessio.usuari;
try {
if (usuari) {
localStorage.setItem(CLAU_MAGATZEM, JSON.stringify(usuari));
} else {
localStorage.removeItem(CLAU_MAGATZEM);
}
} catch {
// Mode privat o quota plena: l'aplicació continua funcionant sense persistència
}
}
return resultat;
resultat;
};// src/magatzem/magatzem.js
import { configureStore } from '@reduxjs/toolkit';
import reductorSessio from '../funcionalitats/sessio/sliceSessio.js';
import reductorCataleg from '../funcionalitats/cataleg/sliceCataleg.js';
import reductorReserves from '../funcionalitats/reserves/sliceReserves.js';
import { persistenciaSessio } from './persistenciaSessio.js';
export const magatzem = configureStore({
reducer: {
sessio: reductorSessio,
cataleg: reductorCataleg,
reserves: reductorReserves
},
middleware: (obtenirPerDefecte) => obtenirPerDefecte().concat(persistenciaSessio)
});Per què un middleware i no useEffect en un component, ni useMagatzemLocal:
| Enfocament | Problema |
|---|---|
useEffect que observa l'usuari |
Només funciona si el component està muntat. Un tancament de sessió des d'un lloc sense aquest component no persisteix |
useMagatzemLocal al component d'accés |
Dues fonts de veritat: el hook i Redux. Es desincronitzen tan bon punt algú despatxa sessioTancada des d'un altre lloc |
| Middleware | Veu totes les accions, estigui muntat el que estigui muntat. Un únic punt, impossible de saltar-se |
I la pàgina d'accés, que ho ajunta tot:
// src/pagines/PaginaAcces.jsx (extracte de la lògica)
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useLocation } from 'react-router';
import { cercarUsuariPerEmail } from '../api/usuaris.js';
import { accesIniciat, sessioIniciada, accesFallit, seleccionarCarregantSessio, seleccionarErrorSessio }
from '../funcionalitats/sessio/sliceSessio.js';
function PaginaAcces() {
const [correu, setCorreu] = useState('');
const despatxar = useDispatch();
const navegar = useNavigate();
const location = useLocation();
const carregant = useSelector(seleccionarCarregantSessio);
const error = useSelector(seleccionarErrorSessio);
// On tornar: ho va deixar RutaProtegida en expulsar
const desti = location.state?.tornarA?.pathname ?? '/';
async function gestionarEnviar(esdeveniment) {
esdeveniment.preventDefault();
despatxar(accesIniciat());
try {
const usuari = await cercarUsuariPerEmail(correu.trim());
if (!usuari) {
despatxar(accesFallit('No hi ha cap compte amb aquest correu.'));
return;
}
despatxar(sessioIniciada(usuari));
navegar(desti, { replace: true });
} catch (fallada) {
despatxar(accesFallit(fallada.message));
}
}
// …el marcatge és el d'11-02, amb carregant i error connectats
}I l'avís que cal dir en veu alta: això no és autenticació. És una cerca per correu sense contrasenya, sense token i sense cap comprovació, acceptable en un projecte d'aprenentatge amb json-server i absolutament insuficient per a producció. A 11-05 es detalla què caldria de veritat.
- Redux: el catàleg, i què no entra a Redux
// src/funcionalitats/cataleg/sliceCataleg.js
import { createSlice } from '@reduxjs/toolkit';
const sliceCataleg = createSlice({
name: 'cataleg',
initialState: { terme: '', ordre: 'model' },
reducers: {
termeCanviat(estat, accio) {
estat.terme = accio.payload;
},
ordreCanviat(estat, accio) {
estat.ordre = accio.payload;
},
filtresReiniciats(estat) {
estat.terme = '';
estat.ordre = 'model';
}
}
});
export const { termeCanviat, ordreCanviat, filtresReiniciats } = sliceCataleg.actions;
export const seleccionarTerme = (estat) => estat.cataleg.terme;
export const seleccionarOrdre = (estat) => estat.cataleg.ordre;
export default sliceCataleg.reducer;Fixa't en el que no hi és: tipo no apareix enlloc, perquè viu a la URL. Tenir-ho als dos llocs seria garantir que un dia es desincronitzen.
La llista del que es va decidir deixar fora de Redux, amb el seu motiu:
| Dada | Per què no hi és a Redux |
|---|---|
| Bicicletes, estacions, reserves | Estat del servidor: és de TanStack Query (A3) |
| Filtre per tipus | És de la URL: ha de ser compartible (A6) |
| Tema | Context: dos valors i cap consumidor exigent |
| Avisos | Context: els emet qualsevol i els pinta el marc |
| Modal obert, esborrany del formulari | Local: ningú més els necessita |
isPending de les mutacions |
Ja el dona Query; duplicar-lo és garantir la desincronització |
La regla que resumeix les sis files: al magatzem global només hi puja el que dos components llunyans necessiten compartir i no encaixa millor en una altra eina. Redux no és el lloc on va tot; és el lloc on va el que li correspon.
- Context: tema i avisos
ProveidorTema ja va quedar escrit a 11-02. Els avisos segueixen el patró de contextos dividits de 07-02, perquè és el que evita la majoria dels repintats inútils:
// src/contextos/avisos.jsx
import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
const ContextEstatAvisos = createContext(null);
const ContextAccionsAvisos = createContext(null);
export function ProveidorAvisos({ children }) {
const [avisos, setAvisos] = useState([]);
const seguentId = useRef(1);
const afegirAvis = useCallback(({ to = 'info', text, duracio = 5000 }) => {
const id = seguentId.current++;
setAvisos((actuals) => [...actuals, { id, to, text }]);
if (duracio > 0) {
setTimeout(() => {
setAvisos((actuals) => actuals.filter((avis) => avis.id !== id));
}, duracio);
}
return id;
}, []);
const descartarAvis = useCallback((id) => {
setAvisos((actuals) => actuals.filter((avis) => avis.id !== id));
}, []);
// Les accions MAI canvien d'identitat: els emissors no es repinten mai
const accions = useMemo(() => ({ afegirAvis, descartarAvis }), [afegirAvis, descartarAvis]);
return (
<ContextAccionsAvisos.Provider value={accions}>
<ContextEstatAvisos.Provider value={avisos}>
{children}
</ContextEstatAvisos.Provider>
</ContextAccionsAvisos.Provider>
);
}
export function useAvisos() {
const context = useContext(ContextAccionsAvisos);
if (!context) throw new Error('useAvisos s\'ha d\'usar dins de ProveidorAvisos.');
return context;
}
export function useLlistaAvisos() {
const context = useContext(ContextEstatAvisos);
if (context === null) throw new Error('useLlistaAvisos s\'ha d\'usar dins de ProveidorAvisos.');
return context;
}El motiu de partir-ho en dos és concret i mesurable. PaginaNovaReserva només necessita emetre avisos; LlistaAvisos només necessita llegir-los. Amb un únic context, cada avís que apareix i desapareix repintaria el formulari sencer. Amb dos, el formulari consumeix un valor que mai canvia d'identitat —gràcies a useCallback amb dependències buides— i no es repinta mai per culpa dels avisos.
// src/components/LlistaAvisos.jsx
import { memo } from 'react';
import { useLlistaAvisos, useAvisos } from '../contextos/avisos.jsx';
import estils from './LlistaAvisos.module.css';
function LlistaAvisos() {
const avisos = useLlistaAvisos();
const { descartarAvis } = useAvisos();
if (avisos.length === 0) return null;
return (
<div className={estils.llista}>
{avisos.map((avis) => (
<div
key={avis.id}
className={`${estils.avis} ${estils[avis.to]}`}
data-testid="aviso"
/* Els errors interrompen; la resta espera el seu torn */
role={avis.to === 'error' ? 'alert' : 'status'}
>
<p>{avis.text}</p>
<button type="button" onClick={() => descartarAvis(avis.id)} aria-label="Tancar avís">
×
</button>
</div>
))}
</div>
);
}
export default memo(LlistaAvisos);La tria de role no és cosmètica: alert interromp la lectura en curs d'un lector de pantalla i status espera que acabi. Un error mereix la interrupció; un «Reserva creada» no.
- El filtre a la URL amb
useSearchParams
useSearchParamsJa s'ha fet servir al catàleg; convé entendre la cadena completa, perquè és el que fa que una URL compartida funcioni:
flowchart LR
A["URL: /?tipo=electrica"] --> B["useSearchParams<br/>tipus = 'electrica'"]
B --> C["claus.bicicletes.llista({tipus:'electrica'})"]
C --> D["Està en memòria cau i fresca?"]
D -- "Sí" --> E["Dades a l'instant"]
D -- "No" --> F["GET /bicicletas?tipus=electrica"]
F --> E
E --> G["LlistaBicicletes"]
H["Clic a 'Urbana'"] --> I["setParametres({tipo:'urbana'}, {replace:true})"]
I --> A
El que es guanya, i que cap altra ubicació de l'estat dona:
- Enllaç compartible: enganxar
/?tipo=electricaen un xat porta qui l'obri exactament a aquesta vista. - Recàrrega fidel:
F5no perd el filtre. - Botó enrere coherent: en entrar en una fitxa i tornar, el filtre continua aplicat.
- Clau de memòria cau gratis: cada filtre té la seva entrada a la memòria cau de Query, així que alternar entre tipus ja vistos és instantani.
I el detall que cal cuidar: la URL és una entrada de l'usuari, i pot portar brutícia. ?tipo=coet no ha de trencar res:
const TIPUS_VALIDS = ['todos', 'urbana', 'electrica', 'carga'];
const tipusBrut = parametres.get('tipo') ?? 'todos';
const tipus = TIPUS_VALIDS.includes(tipusBrut) ? tipusBrut : 'todos';Sense aquest sanejament, un valor inventat produiria una clau de consulta nova, una petició inútil i una llista buida sense explicació.
- Rutes protegides connectades a la sessió real
// src/components/RutaProtegida.jsx
import { Navigate, Outlet, useLocation } from 'react-router';
import { useSelector } from 'react-redux';
import { seleccionarUsuari, seleccionarCarregantSessio }
from '../funcionalitats/sessio/sliceSessio.js';
import EsqueletPagina from './EsqueletPagina.jsx';
function RutaProtegida() {
const usuari = useSelector(seleccionarUsuari);
const carregant = useSelector(seleccionarCarregantSessio);
const location = useLocation();
// Mentre no se sap si hi ha sessió, NO es decideix res
if (carregant) return <EsqueletPagina files={3} />;
if (!usuari) {
// state.tornarA: PaginaAcces l'usa per tornar aquí després d'identificar-se
return <Navigate to="/acceso" replace state={{ tornarA: location }} />;
}
return <Outlet />;
}
export default RutaProtegida;// src/components/RequereixRol.jsx
import { Navigate, Outlet } from 'react-router';
import { useSelector } from 'react-redux';
import { seleccionarUsuari } from '../funcionalitats/sessio/sliceSessio.js';
function RequereixRol({ rol }) {
const usuari = useSelector(seleccionarUsuari);
// Sense permís NO es redirigeix a /acceso: la persona ja està identificada.
// Enviar-la a l'accés suggeriria que el problema és de sessió, i no ho és.
if (usuari?.rol !== rol) {
return <Navigate to="/sin-permisos" replace />;
}
return <Outlet />;
}
export default RequereixRol;L'estat carregant sembla innecessari amb la sessió llegida de forma síncrona de localStorage, i ho és avui; es conserva perquè el dia que la sessió es validi contra el servidor —el normal en producció— hi haurà un instant en què no se sap si hi ha sessió, i sense aquesta guarda aquest instant expulsaria a /acceso algú que sí que estava identificat. És un parpelleig clàssic i molt molest.
I l'advertència que cal repetir cada vegada que apareix aquest codi: això és control d'accés d'interfície, no de seguretat. Impedeix que algú navegui per error a una pantalla que no li correspon, i res més. Qualsevol pot obrir les eines de desenvolupament, modificar l'estat de Redux i veure /taller; i si el botó «Enviar a manteniment» dispara un PATCH que el servidor accepta sense comprovar res, el dany és real. L'autorització es comprova al servidor, a cada petició, sempre. Allò del client és comoditat.
- Gestió d'errors de cap a cap
Amb la xarxa connectada apareixen errors de veritat. La pregunta que cal respondre per a cadascun és qui el mostra, i aquesta taula és la resposta del projecte:
| Situació | Qui el mostra | Què veu la persona | Es recupera amb |
|---|---|---|---|
| Fallada de xarxa en carregar el catàleg | La mateixa pantalla (isError) |
Avís amb missatge i botó «Reintentar» | refetch() |
| 500 del servidor en carregar | Ídem | Ídem | refetch(), després de dos reintents automàtics |
| 404 d'una bicicleta inexistent | La pantalla de la fitxa | «Aquesta bicicleta no existeix» + enllaç al catàleg | Navegant |
URL inexistent (/inventada) |
Ruta * |
PaginaNoTrobada |
Navegant |
| Rol insuficient | RequereixRol |
PaginaSensePermisos |
Canviant de sessió |
| Sessió caducada (401) | Middleware de peticio → tancament de sessió |
Redirecció a /acceso amb avís |
Identificant-se |
| Error en mutar (crear reserva) | Avís en línia, sense sortir | «No s'ha pogut crear la reserva» | Tornant a enviar |
| Excepció en renderitzar una ruta | errorElement |
PaginaErrorRuta, amb capçalera i peu intactes |
Navegant o recarregant |
| Excepció fora de l'enrutador | LimitError de main.jsx |
Pantalla de fallada general | Recarregant |
Fragment lazy que no carrega |
errorElement de la ruta |
Ídem, amb invitació a recarregar | Recarregant |
flowchart TD
A["Hi ha hagut un error"] --> B{"És de dades<br/>o de renderitzat?"}
B -- "Renderitzat" --> C{"Dins d'una ruta?"}
C -- "Sí" --> D["errorElement<br/>PaginaErrorRuta"]
C -- "No" --> E["LimitError<br/>pantalla general"]
B -- "Dades" --> F{"Consulta o mutació?"}
F -- "Consulta" --> G{"Quin codi?"}
G -- "404" --> H["Missatge propi de la pantalla"]
G -- "401" --> I["Tancar sessió i redirigir a /acceso"]
G -- "altres" --> J["Avís amb Reintentar (refetch)"]
F -- "Mutació" --> K["Avís en línia<br/>sense perdre les dades escrites"]
El principi que ordena tot el quadre: com més localitzat sigui l'error, més localitzada ha de ser la seva resposta. Que falli una consulta del catàleg no justifica ensorrar l'aplicació sencera; que falli el renderitzat d'un component sí que justifica substituir aquesta branca. I en cap cas es mostra una pantalla en blanc.
La sessió caducada es resol a la capa de dades, perquè cap pantalla no hagi de recordar-se'n:
// src/api/client.js — afegit a la gestió d'errors
import { magatzem } from '../magatzem/magatzem.js';
import { sessioTancada } from '../funcionalitats/sessio/sliceSessio.js';
if (resposta.status === 401) {
magatzem.dispatch(sessioTancada());
// L'enrutador reaccionarà: RutaProtegida veurà usuari === null i redirigirà
}És l'única concessió de src/api/ a alguna cosa que no és HTTP, i s'accepta perquè l'alternativa —repetir la comprovació del 401 a cada hook— és pitjor. Tot i així, s'importa el magatzem, no React: la capa continua sent utilitzable fora d'un component.
main.jsx definitiu
main.jsx definitiu// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RouterProvider } from 'react-router';
import { 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>
);// src/contextos/Proveidors.jsx
import { ProveidorTema } from './ProveidorTema.jsx';
import { ProveidorAvisos } from './avisos.jsx';
export function Proveidors({ children }) {
return (
<ProveidorTema>
<ProveidorAvisos>{children}</ProveidorAvisos>
</ProveidorTema>
);
}L'ordre no és arbitrari, i cada nivell té la seva raó:
| Nivell | Per què hi és |
|---|---|
StrictMode |
Detecta efectes no idempotents en desenvolupament (04-03). El més extern |
LimitError |
Ha de poder capturar una fallada de qualsevol proveïdor, així que va per fora de tots |
QueryClientProvider |
Fora de Redux: un middleware podria voler invalidar consultes, i no al revés |
Provider (Redux) |
Fora de l'enrutador: RutaProtegida i RequereixRol llegeixen la sessió |
Proveidors |
Tema i avisos els necessita tot l'arbre de rutes |
RouterProvider |
El més intern. Tot l'anterior ha d'estar disponible dins de les rutes |
La regla general, i val per a qualsevol projecte: un proveïdor va per fora de tot el que el consumeix. RouterProvider sempre és l'últim perquè les rutes consumeixen tota la resta.
- El flux complet d'una reserva
Tot el d'aquesta lliçó, en un únic recorregut:
sequenceDiagram
participant U as Ana (usuària)
participant P as PaginaNovaReserva
participant V as validarReserva
participant M as useCrearReserva
participant A as src/api/reserves.js
participant S as json-server
participant Q as Memòria cau de Query
participant C as PaginaCataleg
U->>P: Omple el formulari i envia
P->>V: validarReserva(dades, bicicletes de la memòria cau)
alt Hi ha errors
V-->>P: { hores: 'La reserva màxima és de 24 hores.' }
P-->>U: Missatges al costat de cada camp (role="alert")
else Vàlid
V-->>P: {}
P->>M: mutate(reserva)
M-->>P: isPending: el botó es desactiva
M->>A: crearReserva(dades)
A->>S: POST /reservas
S-->>A: 201 + reserva creada
A-->>M: objecte reserva
M->>Q: invalidateQueries(reserves de l'usuari)
M->>Q: invalidateQueries(bicicletes)
M-->>P: onSuccess
P->>U: Avís «Reserva creada correctament»
P->>U: navegar('/reservas', replace)
Q->>S: GET /reservas?usuari=usr-01 (refresc automàtic)
S-->>Q: llista actualitzada
Q-->>C: En tornar al catàleg, dades ja fresques
end
Fixa't en el que no apareix al diagrama i tanmateix passa: ningú escriu «recarregar la llista de reserves». La invalidació marca les dades com a obsoletes i Query decideix quan demanar-les: a l'instant si hi ha un component muntat observant aquesta clau, o en la següent ocasió si no n'hi ha. Aquesta és la feina que 07-06 va argumentar que no valia la pena reimplementar, i aquí es veu per què.
- Comprovació manual del recorregut
Abans d'escriure la primera prova automàtica (11-04), el recorregut complet a mà, amb la pestanya de xarxa oberta:
| # | Acció | Què ha de passar |
|---|---|---|
| 1 | Arrencar amb npm run dev:todo i obrir / |
Esquelet, després la llista. Una sola petició GET /bicicletas |
| 2 | Anar a /estaciones i tornar a / |
La segona vegada no hi ha petició: staleTime de 30 s |
| 3 | Esperar 40 s i tornar | Ara sí que refresca, i la llista no parpelleja (isFetching, no isPending) |
| 4 | Prémer «Elèctrica» | URL amb ?tipo=electrica, petició nova, dos resultats |
| 5 | Recarregar la pàgina | El filtre continua aplicat |
| 6 | Copiar la URL en una altra pestanya | Mateixa vista filtrada |
| 7 | Escriure «càrrega» al cercador | Cap petició: es filtra al client |
| 8 | Anar a /reservas sense sessió |
Redirecció a /acceso |
| 9 | Entrar amb [email protected] |
Torna a /reservas, no a l'inici |
| 10 | Recarregar | La sessió es manté |
| 11 | Crear una reserva vàlida | Avís d'èxit, redirecció, la reserva apareix a la llista |
| 12 | Tornar al catàleg | L'estat de la bicicleta s'ha actualitzat |
| 13 | Cancel·lar una reserva | La fila canvia a l'instant; després arriba la confirmació |
| 14 | Aturar json-server i cancel·lar una altra |
La fila canvia i torna enrere, amb avís d'error |
| 15 | Amb l'API aturada, recarregar / |
Avís d'error amb «Reintentar» |
| 16 | Arrencar l'API i prémer «Reintentar» | La llista apareix |
| 17 | Obrir /bicicletas/no-existe |
Missatge propi de bicicleta inexistent |
| 18 | Amb l'Ana, obrir /taller |
PaginaSensePermisos |
| 19 | Entrar amb [email protected] i obrir /taller |
Es veu, amb manteniment primer |
| 20 | Tancar sessió | Tornada al catàleg, /reservas protegida una altra vegada |
Els passos 13 i 14 són els que cal fer amb calma: l'actualització optimista és la part que més falla en silenci, i veure la reversió amb els propis ulls és l'única manera de saber que està ben cablejada.
Errors Habituals i Consells
- Desar les dades del servidor en
useStateambuseEffect. És el patró que TanStack Query existeix per eliminar: sense memòria cau, sense deduplicació, amb condicions de carrera i amb l'estat d'error a mig fer. Si apareix unuseEffectque fafetch, alguna cosa s'ha torçat. - Duplicar
isPendingen unuseStatepropi. Produeix el botó que es queda carregant per sempre perquè la branca d'error va oblidar posar-lo afalse. L'estat de la mutació ja existeix: fes-lo servir. - Oblidar
cancelQueriesaonMutate. És la fallada intermitent perfecta: una consulta en curs aterra després de l'actualització optimista i restaura el valor antic. Falla una vegada de cada deu i es diagnostica fatal. - No retornar el context de
onMutate. Sense la foto anterior no hi ha reversió possible, i la interfície es queda mostrant un canvi que el servidor va rebutjar. - Reintentar mutacions automàticament. Un
POSTreintentat després d'un temps d'espera exhaurit pot crear dues reserves.retry: falsea les mutacions i botó de reintent per a la persona. - Reintentar un 404. Tres peticions i tres segons per arribar a la mateixa resposta. La funció de
retryha de distingir 4xx de 5xx. - Posar la mateixa dada en dos llocs. El filtre per tipus a la URL i a Redux és la recepta garantida de la desincronització. Una dada, un propietari.
- Confiar en
RutaProtegidacom a mesura de seguretat. És comoditat d'interfície. L'autorització es comprova al servidor a cada petició, sense excepció. - Invalidar
['bicicletas']des de la pàgina en lloc del hook. Si la invalidació és una conseqüència del domini, va al hook de mutació: així passa des de qualsevol pantalla que faci servir aquest hook, avui i d'aquí a un any. - No sanejar els paràmetres de la URL.
?tipo=coetprodueix una clau de memòria cau nova, una petició inútil i una llista buida sense explicació. - Consell: tingues obertes les DevTools de TanStack Query mentre desenvolupes. Veure les claus, el seu estat (fresca, obsoleta, inactiva) i els seus refrescos converteix en obvi el que d'altra manera són hores de conjectures.
- Consell: prova sempre amb l'API aturada. És la manera més ràpida de comprovar que els estats d'error existeixen de veritat i que se'n pot sortir.
Exercicis
Exercici 1. Implementa la funcionalitat del taller (H7): el hook useCanviarEstatBicicleta amb actualització optimista i la seva connexió a PaginaTaller. Ha d'actualitzar tant la llista de bicicletes com la fitxa individual si està en memòria cau, revertir davant d'error, i avisar del resultat. Indica quines claus invalides i per què, i què passa si l'operari prem dos botons seguits molt ràpid.
Exercici 2. Un company informa d'aquesta fallada: «si entro al catàleg, filtro per elèctrica, entro en una fitxa i torno enrere, a vegades veig la llista sense filtrar durant un instant». Diagnostica les dues causes possibles, explica com distingir quina és, i corregeix la que correspongui al projecte tal com està escrit en aquesta lliçó.
Exercici 3. L'API real de producció retornarà 401 quan el token caduqui, i l'equip vol que la persona no perdi el que estava fent: en lloc d'expulsar-la a l'instant, s'ha de veure un avís amb un botó per tornar a identificar-se que, en fer-ho, la retorni a la pantalla on era. Dissenya la solució indicant quina capa s'encarrega de què, escriu el codi de les parts noves, i explica quin problema té la solució actual de l'apartat 16.
Solucions
Solució 1.
// src/consultes/bicicletes.js — continuació
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { actualitzarEstatBicicleta } from '../api/bicicletes.js';
export function useCanviarEstatBicicleta() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, estat }) => actualitzarEstatBicicleta(id, estat),
onMutate: async ({ id, estat }) => {
// 1) Aturar TOTES les consultes de bicicletes, llistes i detalls
await client.cancelQueries({ queryKey: claus.bicicletes.totes() });
// 2) Foto de totes les entrades afectades: hi ha diverses llistes (una per tipus)
const llistesAnteriors = client.getQueriesData({ queryKey: claus.bicicletes.totes() });
// 3) Actualitzar cada llista en memòria cau
client.setQueriesData({ queryKey: claus.bicicletes.totes() }, (dades) => {
if (!Array.isArray(dades)) return dades; // el detall no és un array
return dades.map((bici) => (bici.id === id ? { ...bici, estat } : bici));
});
// 4) I la fitxa individual, si està en memòria cau
client.setQueryData(claus.bicicletes.detall(id), (bici) =>
bici ? { ...bici, estat } : bici
);
return { llistesAnteriors, id };
},
onError: (error, variables, context) => {
// Reversió completa: es restaura cada entrada amb la seva clau original
context?.llistesAnteriors?.forEach(([clau, dades]) => {
client.setQueryData(clau, dades);
});
},
onSettled: (dades, error, variables) => {
client.invalidateQueries({ queryKey: claus.bicicletes.totes() });
client.invalidateQueries({ queryKey: claus.bicicletes.detall(variables.id) });
}
});
}// src/pagines/PaginaTaller.jsx (extracte)
function PaginaTaller() {
const consulta = useBicicletes();
const mutacio = useCanviarEstatBicicleta();
const { afegirAvis } = useAvisos();
function gestionarCanvi(bicicleta, estatNou) {
mutacio.mutate(
{ id: bicicleta.id, estat: estatNou },
{
onSuccess: () =>
afegirAvis({
to: 'exit',
text: `${bicicleta.model} ara està ${estatNou === 'mantenimiento' ? 'en manteniment' : 'disponible'}.`
}),
onError: (error) =>
afegirAvis({ to: 'error', text: `No s'ha pogut actualitzar. ${error.message}` })
}
);
}
if (consulta.isPending) return <EsqueletPagina files={4} />;
// …resta del marcatge d'11-02, amb onClick={() => gestionarCanvi(bici, 'mantenimiento')}
}Quines claus s'invaliden i per què:
| Clau | Motiu |
|---|---|
['bicicletes'] |
Arriba per prefix a totes les llistes filtrades: {tipus:'todos'}, {tipus:'urbana'}… Qualsevol d'elles pot contenir aquesta bicicleta |
['bicicletes','detall',id] |
La fitxa individual és una entrada diferent i no penja d'una llista |
['reserves'] |
No s'invalida: canviar l'estat d'una bicicleta no altera cap reserva existent. Invalidar-la seria una petició inútil |
Ús de setQueriesData en plural, amb la comprovació Array.isArray: la fitxa individual comparteix el prefix ['bicicletes'] i no és un array, així que sense aquesta guarda el map rebentaria. És el preu de les claus jeràrquiques i cal tenir-ho present.
Si l'operari prem dos botons seguits molt ràpid: es disparen dues mutacions en paral·lel, i aquí hi ha un risc real. La segona executa el seu onMutate després que la primera ja hagi modificat la memòria cau, així que la seva foto llistesAnteriors inclou el canvi de la primera. Si la segona falla i la primera va anar bé, la reversió de la segona restaura un estat correcte —el que incloïa el canvi u—; fins aquí, bé. El problema real apareix si falla la primera: la seva reversió restaura una foto anterior a totes dues, esborrant visualment el canvi de la segona, que sí que va tenir èxit. L'onSettled amb invalidateQueries corregeix la discrepància tan bon punt arriba la resposta del servidor, però durant un instant la interfície menteix.
Tres maneres de resoldre-ho, de menys a més:
- Desactivar el botó d'aquesta bicicleta mentre la seva mutació està en curs (
mutacio.isPending && mutacio.variables?.id === bici.id). Simple, suficient i honest amb l'usuari. - Usar
useMutationStateper portar el registre de les mutacions pendents i aplicar-les totes en recalcular la memòria cau. - Serialitzar amb
scope, l'opció de TanStack Query v5 que executa les mutacions d'un mateix àmbit una darrere l'altra:
return useMutation({
scope: { id: 'estat-bicicletes' }, // s'encuen, no se solapen
mutationFn: ({ id, estat }) => actualitzarEstatBicicleta(id, estat),
// …
});Per al taller, l'1 i la 3 juntes són la resposta proporcionada.
Solució 2.
Les dues causes possibles, i són molt diferents:
Causa A — el filtre no és a la URL, o no es llegeix en muntar. Si PaginaCataleg desés el tipus en un useState inicialitzat a 'todos' i només el sincronitzés amb la URL en un useEffect, en tornar enrere es renderitzaria primer amb 'todos' —llista completa— i després amb el valor correcte. El parpelleig seria sempre, no «a vegades».
Causa B — la memòria cau de la llista sense filtrar es mostra mentre arriba la filtrada. Si en tornar enrere la clau ['bicicletes', {tipus:'electrica'}] és fora de la memòria cau —han passat més de gcTime, o és la primera vegada— i el component conserva dades anteriors, es veu la llista antiga durant el refresc.
Com distingir-les:
| Observació | Apunta a |
|---|---|
| Passa sempre, fins i tot sense xarxa | A: és un problema de sincronització d'estat |
| Passa només a vegades, i més si trigues a tornar | B: és un problema de memòria cau |
La URL a la barra ja porta ?tipo=electrica en l'instant del parpelleig |
A: la URL està bé, la pantalla la ignora |
| A les DevTools de Query es veu la clau antiga activa | B |
| Amb la xarxa alentida el parpelleig s'allarga | B |
Al projecte tal com està escrit, la causa és la B, perquè l'apartat 8 llegeix el tipus directament de useSearchParams al render —no hi ha useState intermedi ni efecte de sincronització—, de manera que A queda descartada per construcció.
La correcció té dues parts. Primer, no arrossegar dades d'una altra clau: TanStack Query v5 no ho fa per defecte, però sí que passa si algú va afegir placeholderData: keepPreviousData sense entendre l'efecte. Si hi és, es treu o s'acompanya d'un senyal visual:
const consulta = useBicicletes({ tipus });
// Si es vol conservar la llista anterior durant el canvi de filtre,
// cal DIR-HO a la interfície, no deixar que sembli el resultat final
const mostrantAnterior = consulta.isPlaceholderData;
<ul className={classes(estils.llista, mostrantAnterior && estils.atenuada)} aria-busy={mostrantAnterior}>I segon, la solució de fons, que a més millora l'experiència: sembrar la memòria cau de la llista filtrada a partir de la completa, ja que el filtre per tipus és un subconjunt d'una dada que gairebé sempre és a la memòria:
export function useBicicletes(filtres = {}) {
const client = useQueryClient();
return useQuery({
queryKey: claus.bicicletes.llista(filtres),
queryFn: ({ signal }) => obtenirBicicletes({ ...filtres, senyal: signal }),
placeholderData: () => {
// Si la llista completa és a la memòria cau, es filtra al client com a valor provisional
const totes = client.getQueryData(claus.bicicletes.llista({}));
if (!totes || !filtres.tipus || filtres.tipus === 'todos') return undefined;
return totes.filter((bici) => bici.tipus === filtres.tipus);
}
});
}Ara, en tornar enrere amb ?tipo=electrica, es veuen les dues bicicletes elèctriques a l'instant —filtrades de la llista completa que ja era a la memòria— i la petició real les confirma després. Ni parpelleig ni llista equivocada.
Solució 3.
Quin problema té la solució de l'apartat 16. És brusca i perd feina. Un dispatch(sessioTancada()) des de peticio expulsa a /acceso en l'instant en què qualsevol petició retorna 401 —inclosa una revalidació en segon pla que la persona no ha demanat—, i s'emporta per davant el formulari a mig escriure. A més, si diverses peticions fallen alhora, es despatxen diversos tancaments de sessió, i la capa de dades pren una decisió de navegació que no li correspon.
Repartiment de responsabilitats:
| Capa | Responsabilitat |
|---|---|
src/api/client.js |
Detectar el 401 i llançar un ErrorApi amb estat: 401. Res més: no despatxa ni navega |
clientConsultes.js |
Un gestor global d'errors que, davant d'un 401, marca la sessió com a caducada (no tancada) |
sliceSessio |
Camp nou caducada, diferent de usuari === null |
Component AvisSessioCaducada |
Mostra el diàleg amb el botó de tornar a identificar-se |
PaginaAcces |
En identificar-se, neteja caducada i retorna a la pantalla desada |
El codi nou:
// src/funcionalitats/sessio/sliceSessio.js — afegits
const estatInicial = {
usuari: llegirSessioDesada(),
carregant: false,
error: null,
caducada: false // hi ha usuari en memòria, però el servidor ja no l'accepta
};
// dins de reducers:
sessioCaducada(estat) {
estat.caducada = true; // l'usuari NO s'esborra: es necessita per tornar
},
sessioRenovada(estat, accio) {
estat.usuari = accio.payload;
estat.caducada = false;
},
export const seleccionarSessioCaducada = (estat) => estat.sessio.caducada;// src/consultes/clientConsultes.js — gestor global
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { magatzem } from '../magatzem/magatzem.js';
import { sessioCaducada } from '../funcionalitats/sessio/sliceSessio.js';
import { ErrorApi } from '../api/client.js';
function gestionarErrorGlobal(error) {
if (error instanceof ErrorApi && error.estat === 401) {
// Idempotent: encara que fallin cinc peticions, l'estat queda igual
magatzem.dispatch(sessioCaducada());
}
}
export const clientConsultes = new QueryClient({
queryCache: new QueryCache({ onError: gestionarErrorGlobal }),
mutationCache: new MutationCache({ onError: gestionarErrorGlobal }),
defaultOptions: { /* …els de l'apartat 5… */ }
});// src/components/AvisSessioCaducada.jsx
import { useSelector, useDispatch } from 'react-redux';
import { useLocation, useNavigate } from 'react-router';
import { seleccionarSessioCaducada, sessioTancada } from '../funcionalitats/sessio/sliceSessio.js';
import Modal from './base/Modal.jsx';
import Boto from './base/Boto.jsx';
function AvisSessioCaducada() {
const caducada = useSelector(seleccionarSessioCaducada);
const location = useLocation();
const navegar = useNavigate();
const despatxar = useDispatch();
if (!caducada) return null;
return (
<Modal obert titol="La teva sessió ha caducat" alTancar={() => {}}>
<p>
Per seguretat, la sessió s'ha tancat després d'un període d'inactivitat.
Torna a identificar-te per continuar on eres.
</p>
<div>
<Boto
onClick={() => navegar('/acceso', { state: { tornarA: location }, replace: false })}
>
Tornar a identificar-me
</Boto>
<Boto
variant="secundari"
onClick={() => {
despatxar(sessioTancada());
navegar('/', { replace: true });
}}
>
Sortir
</Boto>
</div>
</Modal>
);
}
export default AvisSessioCaducada;AvisSessioCaducada es col·loca a Disseny, al costat de LlistaAvisos, perquè estigui disponible a qualsevol pantalla. I PaginaAcces despatxa sessioRenovada en lloc de sessioIniciada quan venia d'una caducitat, amb la qual cosa caducada torna a false i la redirecció fa servir el state.tornarA que va deixar el modal.
El que es guanya amb aquest disseny:
| Abans | Ara |
|---|---|
| Expulsió immediata en rebre un 401 | Diàleg que explica el que passa |
| El formulari a mig escriure es perd | Continua muntat darrere del diàleg |
| Cinc peticions fallides, cinc tancaments | Una acció idempotent |
| La capa de dades navega | La capa de dades només informa |
| No hi ha manera de tornar al que es feia | state.tornarA retorna exactament allà |
I la matisació imprescindible, perquè és la trampa mental de tot aquest apartat: la sessió caducada no es decideix al client. Es detecta quan el servidor ho diu, amb un 401, i cap comprovació de data al navegador no substitueix això. Si el client confiés en el seu propi rellotge per decidir que el token continua vigent, n'hi hauria prou d'avançar-lo per saltar-se l'expiració.
Conclusió
CicloUrbano ja és una aplicació de veritat. Les dades vénen de la xarxa, es guarden en memòria cau, s'invaliden i es mostren; la sessió existeix i sobreviu a la recàrrega; els filtres viuen a la URL i són compartibles; i cada error té un propietari que sap mostrar-lo.
El primer que en queda és la taula d'assignació d'estat aplicada dada per dada, que és la que respon per endavant a la pregunta que més vegades apareix en un projecte de React. Les seves dues files crítiques: els recursos remots no van a Redux, i l'estat d'una mutació no es duplica en un useState.
A sota hi ha la capa d'accés a dades, src/api/, amb una regla que es compleix sense excepció: parla HTTP i no sap res de React. L'embolcall peticio concentra la URL base, les capçaleres, la comprovació de resposta.ok —perquè fetch no llança amb un 500—, el cas 204, el temps d'espera amb AbortController combinat amb la senyal de Query, i una classe ErrorApi que conserva el codi perquè a dalt es pugui distingir un 404 d'un 500 d'una fallada de xarxa. A canvi d'aquesta disciplina es guanya poder provar-la sense muntar res, reutilitzar-la fora de React i tenir un únic punt on afegir l'autenticació real.
A TanStack Query queden fixats uns valors per defecte que no són els del manual sinó els d'aquest projecte: staleTime de 30 s per no llançar una tempesta de peticions en navegar, gcTime de 5 min, revalidació en recuperar el focus, una funció de retry que no reintenta els 4xx perquè la resposta no canviarà, i retry: false a les mutacions perquè un POST reintentat pot crear dues reserves. A sobre, la fàbrica de claus jeràrquica, que fa que invalidar el pare arribi als fills per prefix, i els hooks del projecte amb enabled per expressar «això depèn d'alguna cosa que encara no tinc» i amb la signal que cancel·la la petició en desmuntar.
De la connexió amb la interfície, tres idees que valen per a qualsevol pantalla: isPending pinta l'esquelet i isFetching només xiuxiueja, perquè substituir la llista a cada revalidació és un parpelleig injustificat; el filtre que forma part de la clau va al servidor i la cerca per text es resol al client; i el recompte de resultats porta aria-live perquè el canvi s'anunciï.
La mutació de crear una reserva deixa el repartiment clar: la validació s'executa contra les dades reals de la memòria cau, la invalidació viu al hook perquè és una conseqüència del domini, i l'avís i la redirecció viuen a la pàgina perquè són decisions d'aquesta pantalla; la redirecció fa servir replace perquè el botó enrere no torni a un formulari ja enviat, i l'error d'una mutació mai treu de la pantalla. L'actualització optimista de confirmar i cancel·lar aporta els seus tres passos innegociables —cancelQueries perquè una consulta en curs no trepitgi el canvi, la foto per poder revertir, i l'onSettled per acabar sincronitzant amb el servidor— i el seu criteri d'aplicació: sí en canvis d'un camp amb resultat predictible, no en creacions i mai en operacions amb diners.
Al costat del client, Redux es queda amb la sessió —persistida mitjançant un middleware, que veu totes les accions estigui muntat el que estigui muntat— i amb el terme i l'ordre del catàleg, amb una llista explícita del que es va decidir deixar fora. El context resol tema i avisos amb el patró de contextos dividits, de manera que qui només emet avisos no es repinta mai quan apareixen. La URL guarda el filtre i dona quatre coses gratis: enllaç compartible, recàrrega fidel, botó enrere coherent i clau de memòria cau; amb el recordatori que la URL és entrada de l'usuari i cal sanejar-la.
Les rutes protegides queden connectades a la sessió real, amb l'estat carregant que evita el parpelleig d'expulsió el dia que la sessió es validi contra el servidor, amb RequereixRol enviant a «sense permisos» i no a «accés» —perquè el problema no és de sessió—, i amb l'advertència que cal repetir sempre: això és comoditat d'interfície, no seguretat; l'autorització es comprova al servidor, a cada petició.
I tanca la lliçó la taula de decisió d'errors, que respon a la pregunta de qui mostra què: la pantalla davant d'una fallada de consulta, amb reintent; un missatge propi davant d'un 404; la ruta * davant d'una URL inexistent; errorElement davant d'una excepció de renderitzat; LimitError davant del que passa fora de l'enrutador; i un avís en línia davant de la fallada d'una mutació, sense perdre el que s'ha escrit. El principi que l'ordena: com més localitzat sigui l'error, més localitzada ha de ser la resposta, i en cap cas una pantalla en blanc.
Tot això s'ha comprovat a mà, amb vint passos i la pestanya de xarxa oberta. I a mà és exactament el problema: demà algú canvia una línia de l'onSettled i ningú repetirà els vint passos. Proves del Projecte converteix aquesta comprovació manual en una xarxa de seguretat automàtica: el pla de proves amb les històries d'11-01 repartides per nivell, les unitàries de validarReserva i els reductors, les de components sobre TargetaBicicleta i FormulariReserva, les d'integració amb MSW sobre pàgines senceres, els tres fluxos de Cypress, la cobertura llegida amb criteri, la integració contínua completa i —l'argument definitiu— una regressió guiada en què es trenca a propòsit la invalidació d'aquesta lliçó per veure exactament quina prova ho caça i quina no.
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
