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

  1. La taula definitiva: quina dada viu on
  2. La capa d'accés a dades: src/api/client.js
  3. Funcions per recurs
  4. Per què aquesta capa no sap res de React
  5. clientConsultes.js i els seus valors per defecte
  6. La fàbrica de claus
  7. Els hooks de consulta del projecte
  8. Connectar el catàleg: dels interruptors als estats reals
  9. La mutació completa: crear una reserva
  10. Actualització optimista: confirmar i cancel·lar
  11. Redux: la sessió
  12. Redux: el catàleg, i què no entra a Redux
  13. Context: tema i avisos
  14. El filtre a la URL amb useSearchParams
  15. Rutes protegides connectades a la sessió real
  16. Gestió d'errors de cap a cap
  17. main.jsx definitiu
  18. El flux complet d'una reserva
  19. Comprovació manual del recorregut

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

  1. La capa d'accés a dades: src/api/client.js

Abans 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ó

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

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

  1. 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
  });
}

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

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

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

  1. 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.
  2. isPending enfront de isFetching. 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.
  3. replace: true en 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.
  4. 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.

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

  1. 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 Canvi d'un camp, resultat predictible, fallada rara i reversible
Confirmar una reserva Ídem
Canviar l'estat d'una bicicleta (taller) Í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

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

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

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

  1. El filtre a la URL amb useSearchParams

Ja 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=electrica en un xat porta qui l'obri exactament a aquesta vista.
  • Recàrrega fidel: F5 no 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ó.

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

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

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

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

  1. 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 useState amb useEffect. É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 un useEffect que fa fetch, alguna cosa s'ha torçat.
  • Duplicar isPending en un useState propi. Produeix el botó que es queda carregant per sempre perquè la branca d'error va oblidar posar-lo a false. L'estat de la mutació ja existeix: fes-lo servir.
  • Oblidar cancelQueries a onMutate. É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 POST reintentat després d'un temps d'espera exhaurit pot crear dues reserves. retry: false a 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 retry ha 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 RutaProtegida com 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=coet produeix 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:

  1. 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.
  2. Usar useMutationState per portar el registre de les mutacions pendents i aplicar-les totes en recalcular la memòria cau.
  3. 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

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

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

© Copyright 2026. Tots els drets reservats