A la lliçó anterior va quedar clar què és l'enrutament al client, per què React no el porta de sèrie i quina de les tres formes d'usar React Router v7 fa servir aquest curs: el mode de dades, amb createBrowserRouter i RouterProvider. Ara toca escriure'l. En aquesta lliçó converteixes CicloUrbano d'una aplicació d'una sola pantalla en una aplicació enrutada: instal·laràs el paquet, crearàs el mapa de rutes a src/rutes.jsx, muntaràs el proveïdor de l'enrutador a main.jsx sense trencar l'ordre de proveïdors que ja existeix, reorganitzaràs el projecte amb una carpeta src/pagines/, substituiràs els <a href="#…"> de la Capcalera per enllaços de veritat —saldant per fi el deute del mòdul 2—, llegiràs l'identificador de la fitxa amb useParams i traslladaràs el filtre del SelectorTipus des d'useState fins a la mateixa URL amb useSearchParams. En acabar, cada pantalla de CicloUrbano tindrà la seva adreça.

Contingut

  1. Punt de partida i pla de la reorganització
  2. Instal·lació del paquet
  3. La carpeta src/pagines/ i què es queda a src/components/
  4. El mapa de rutes: src/rutes.jsx
  5. Muntar l'enrutador a main.jsx
  6. Per què els proveïdors van fora de l'enrutador
  7. Enllaços: <Link> enfront de <a href>
  8. <NavLink> i la classe activa: la nav de Capcalera
  9. Segments dinàmics i useParams
  10. Què fer quan l'identificador no existeix
  11. Paràmetres de consulta amb useSearchParams
  12. La ruta comodí i la pàgina de no trobat
  13. Com tria React Router la millor coincidència

  1. Punt de partida i pla de la reorganització

Així és avui App.jsx, al final del mòdul 5: una única pantalla que mostra el catàleg amb el seu cercador, el seu filtre i el seu formulari de reserva, tot embolcallat en Disseny.

// src/App.jsx — el punt de partida (versió resumida)
import Disseny from './components/Disseny.jsx';
import CercadorBicicletes from './components/CercadorBicicletes.jsx';
import SelectorTipus from './components/SelectorTipus.jsx';
import LlistaBicicletes from './components/LlistaBicicletes.jsx';
import PanellReserva from './components/PanellReserva.jsx';
import { useState } from 'react';
import { bicicletes, estacions } from './dades/domini.js';

function App() {
  const [terme, setTerme] = useState('');
  const [tipusTriat, setTipusTriat] = useState('todos');
  const [idSeleccionada, setIdSeleccionada] = useState(null);

  const visibles = bicicletes
    .filter((bici) => tipusTriat === 'todos' || bici.tipus === tipusTriat)
    .filter((bici) => bici.model.toLowerCase().includes(terme.toLowerCase()));

  return (
    <Disseny>
      <CercadorBicicletes terme={terme} alCanviarTerme={setTerme} />
      <SelectorTipus tipusTriat={tipusTriat} alCanviarTipus={setTipusTriat} />
      <LlistaBicicletes
        bicicletes={visibles}
        estacions={estacions}
        idSeleccionada={idSeleccionada}
        alSeleccionar={setIdSeleccionada}
      />
      <PanellReserva />
    </Disseny>
  );
}

export default App;

El pla d'aquesta lliçó, en ordre:

flowchart LR
    A["1. npm install<br/>react-router"] --> B["2. Crear src/pagines/<br/>amb una pantalla per ruta"]
    B --> C["3. src/rutes.jsx<br/>createBrowserRouter"]
    C --> D["4. main.jsx<br/>RouterProvider"]
    D --> E["5. Capcalera<br/>NavLink"]
    E --> F["6. useParams<br/>useSearchParams"]

En acabar, App.jsx desapareix com a pantalla: el seu contingut passa a PaginaCataleg i el paper d'arrel l'assumeix l'enrutador. És la reestructuració més gran del curs fins ara, així que convé fer-la per passos i comprovar que l'aplicació arrenca a cadascun.

  1. Instal·lació del paquet

npm install react-router

Això és tot. Ni complements de Vite, ni configuració addicional: en el mode de dades, React Router és una dependència normal.

Sobre el nom del paquet, perquè és la primera confusió amb què et trobaràs:

Paquet Versió Què és
react-router-dom v6 i anteriors El paquet que s'instal·lava en aplicacions web. Exportava BrowserRouter, Link, etc.
react-router v6 i anteriors El nucli independent de plataforma. No s'instal·lava directament
react-router v7 L'únic paquet que necessites. D'aquí surt tot
react-router-dom v7 Es continua publicant, però només reexporta react-router. Existeix per no trencar projectes migrats

Conseqüència pràctica: tots els exemples d'internet que vegis amb from 'react-router-dom' funcionen igual canviant la importació a from 'react-router'. Tria'n un i sigues coherent; tenir-los tots dos instal·lats és una font d'errors de context molt difícils de diagnosticar, perquè pots acabar amb dues còpies de l'enrutador en memòria.

Comprova la versió instal·lada:

npm list react-router
# [email protected]
# └── [email protected]

  1. La carpeta src/pagines/ i què es queda a src/components/

Abans d'escriure el mapa cal decidir on viu cada cosa. Introduïm una carpeta nova:

src/
├── components/     ← peces reutilitzables d'interfície
├── contextos/
├── dades/
├── hooks/
├── pagines/         ← NOU: una pantalla per ruta
├── reductors/
├── utilitats/
├── main.jsx
└── rutes.jsx        ← NOU: el mapa de rutes

La regla que separa totes dues carpetes:

src/pagines/ src/components/
Què és La pantalla completa associada a una ruta Una peça d'interfície reutilitzable
Qui la renderitza L'enrutador, i només ell Altres components o pàgines
Quantes vegades apareix Una, a la seva ruta Les que calgui
Pot llegir useParams Sí, és el seu lloc natural Preferiblement no: rep-lo per props
Exemples PaginaCataleg, PaginaAcces TargetaBicicleta, Avis, Modal

Aquest darrer punt és més que una convenció d'ordre: una pàgina pot dependre de l'enrutador; un component reutilitzable, com menys millor. Si TargetaBicicleta crida useParams() per dins, deixa de poder-se fer servir en una pantalla que no tingui aquest paràmetre, i provar-la al mòdul 9 obligarà a embolcallar-la en un enrutador fals. La pàgina llegeix el paràmetre i l'hi passa per props: el component continua sent una funció dels seus props, com a 02-03.

Aquestes són les pàgines de CicloUrbano. Comencem amb versions mínimes i les anirem completant:

// src/pagines/PaginaCataleg.jsx
import { useState } from 'react';
import CercadorBicicletes from '../components/CercadorBicicletes.jsx';
import SelectorTipus from '../components/SelectorTipus.jsx';
import LlistaBicicletes from '../components/LlistaBicicletes.jsx';
import { bicicletes, estacions } from '../dades/domini.js';

function PaginaCataleg() {
  const [terme, setTerme] = useState('');
  const [tipusTriat, setTipusTriat] = useState('todos');

  const visibles = bicicletes
    .filter((bici) => tipusTriat === 'todos' || bici.tipus === tipusTriat)
    .filter((bici) => bici.model.toLowerCase().includes(terme.toLowerCase()));

  return (
    <section>
      <h2>Catàleg de bicicletes</h2>
      <CercadorBicicletes terme={terme} alCanviarTerme={setTerme} />
      <SelectorTipus tipusTriat={tipusTriat} alCanviarTipus={setTipusTriat} />
      <LlistaBicicletes bicicletes={visibles} estacions={estacions} />
    </section>
  );
}

export default PaginaCataleg;
// src/pagines/PaginaEstacions.jsx
import TargetaEstacio from '../components/TargetaEstacio.jsx';
import { estacions, bicicletes } from '../dades/domini.js';

function PaginaEstacions() {
  return (
    <section>
      <h2>Estacions</h2>
      <ul>
        {estacions.map((estacio) => (
          <li key={estacio.id}>
            <TargetaEstacio
              estacio={estacio}
              bicicletesEnEstacio={bicicletes.filter((b) => b.estacioId === estacio.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

export default PaginaEstacions;
// src/pagines/PaginaReserves.jsx
import { useReserves } from '../contextos/ContextReserves.jsx';
import PanellReserves from '../components/PanellReserves.jsx';

function PaginaReserves() {
  const { estat } = useReserves();

  return (
    <section>
      <h2>Les meves reserves</h2>
      <PanellReserves reserves={estat.reserves} />
    </section>
  );
}

export default PaginaReserves;

PaginaAcces la desenvoluparàs a 06-05 (necessita iniciarSessio i el retorn a la destinació original), així que de moment n'hi ha prou amb un esquelet. I falta la més important per a aquesta lliçó:

// src/pagines/PaginaNoTrobada.jsx
import { Link } from 'react-router';

function PaginaNoTrobada() {
  return (
    <section>
      <h2>Aquesta pàgina no existeix</h2>
      <p>
        L'adreça que has escrit no correspon a cap pantalla de CicloUrbano.
        Pot ser que l'enllaç estigui desactualitzat o que hi hagi un error tipogràfic.
      </p>
      <Link to="/">Tornar al catàleg</Link>
    </section>
  );
}

export default PaginaNoTrobada;

  1. El mapa de rutes: src/rutes.jsx

Aquí hi ha el cor del mode de dades: el mapa és un array d'objectes JavaScript, no marcatge JSX.

// src/rutes.jsx
import { createBrowserRouter } from 'react-router';

import PaginaCataleg from './pagines/PaginaCataleg.jsx';
import PaginaFitxaBicicleta from './pagines/PaginaFitxaBicicleta.jsx';
import PaginaEstacions from './pagines/PaginaEstacions.jsx';
import PaginaDetallEstacio from './pagines/PaginaDetallEstacio.jsx';
import PaginaReserves from './pagines/PaginaReserves.jsx';
import PaginaNovaReserva from './pagines/PaginaNovaReserva.jsx';
import PaginaAcces from './pagines/PaginaAcces.jsx';
import PaginaTaller from './pagines/PaginaTaller.jsx';
import PaginaNoTrobada from './pagines/PaginaNoTrobada.jsx';

export const router = createBrowserRouter([
  { path: '/', element: <PaginaCataleg /> },
  { path: '/bicicletas/:bicicletaId', element: <PaginaFitxaBicicleta /> },
  { path: '/estaciones', element: <PaginaEstacions /> },
  { path: '/estaciones/:estacionId', element: <PaginaDetallEstacio /> },
  { path: '/reservas', element: <PaginaReserves /> },
  { path: '/reservas/nueva', element: <PaginaNovaReserva /> },
  { path: '/acceso', element: <PaginaAcces /> },
  { path: '/taller', element: <PaginaTaller /> },
  { path: '*', element: <PaginaNoTrobada /> }
]);

Desgranem les decisions:

  • El fitxer és .jsx, no .js. Conté JSX a la propietat element, així que l'extensió ho ha de permetre o Vite es queixarà.
  • path és el patró d'URL, amb / inicial a les rutes de primer nivell. Els segments que comencen per : són dinàmics.
  • element rep un element de React ja creat (<PaginaCataleg />), no el component (PaginaCataleg). És una diferència subtil però estricta: passar el component sense instanciar és un error clàssic i produeix una pantalla en blanc.
  • S'exporta router amb nom, seguint la convenció del projecte: export default per a components, exportació amb nom per a la resta.
  • createBrowserRouter es crida una sola vegada, fora de qualsevol component. Si el criessis dins d'un component, cada render crearia un enrutador nou i perdries tot l'estat de navegació.
  • La ruta * va al final per llegibilitat, no per necessitat: l'ordre ja no decideix res (apartat 13).

Encara no hi ha imbricació. Disseny encara no és al mapa, així que les pantalles es pinten soltes, sense capçalera ni peu. És un estat intermedi deliberat: ho arreglaràs a 06-03, que és justament la lliçó de rutes imbricades.

  1. Muntar l'enrutador a main.jsx

Recorda com estava el punt d'entrada al final del mòdul 5:

// src/main.jsx — ABANS
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import LimitError from './components/LimitError.jsx';
import { ProveidorTema } from './contextos/ContextTema.jsx';
import { ProveidorUsuari } from './contextos/ContextUsuari.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}>
      <ProveidorTema>
        <ProveidorUsuari>
          <App />
        </ProveidorUsuari>
      </ProveidorTema>
    </LimitError>
  </StrictMode>
);

El canvi consisteix a substituir <App /> per <RouterProvider router={router} />:

// src/main.jsx — DESPRÉS
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from './rutes.jsx';
import LimitError from './components/LimitError.jsx';
import { ProveidorTema } from './contextos/ContextTema.jsx';
import { ProveidorUsuari } from './contextos/ContextUsuari.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}>
      <ProveidorTema>
        <ProveidorUsuari>
          <RouterProvider router={router} />
        </ProveidorUsuari>
      </ProveidorTema>
    </LimitError>
  </StrictMode>
);

Punts que mereixen comentari:

  • RouterProvider no accepta children. Tot el que es pinta a sota surt del mapa de rutes. Si escrius <RouterProvider router={router}><Algo /></RouterProvider>, aquest <Algo /> no apareixerà mai.
  • App.jsx ja no s'importa. El pots esborrar o deixar-lo buit; el seu paper l'acompleixen ara rutes.jsx (què es pinta) i Disseny (com s'enquadra, a partir de 06-03).
  • StrictMode continua a l'arrel, com des de 01-03. Continuarà provocant el doble muntatge en desenvolupament, i això inclou els efectes de les pàgines: res de nou respecte a 05-02.
  • ProveidorReserves i ProveidorAvisos no apareixen aquí. A 06-03 els col·locaràs dins de Disseny, la ruta arrel, junt amb la resta del marc de l'aplicació. També podries pujar-los a main.jsx; la decisió es justifica a la lliçó següent.

Amb això ja pots arrencar npm run dev, escriure http://localhost:5173/estaciones a la barra d'adreces i veure el llistat d'estacions. És el primer moment del curs en què la URL significa alguna cosa.

  1. Per què els proveïdors van fora de l'enrutador

L'ordre StrictMode > LimitError > ProveidorTema > ProveidorUsuari > RouterProvider no és arbitrari. Cada capa n'embolcalla la següent per una raó concreta:

flowchart TD
    SM["StrictMode<br/><i>comprovacions de desenvolupament</i>"] --> LE["LimitError<br/><i>captura fallades de TOT,<br/>incloent l'enrutador</i>"]
    LE --> PT["ProveidorTema<br/><i>sobreviu als canvis de ruta</i>"]
    PT --> PU["ProveidorUsuari<br/><i>la sessió NO es perd en navegar</i>"]
    PU --> RP["RouterProvider<br/><i>decideix quina pantalla pintar</i>"]
    RP --> PAG["La pàgina activa<br/><i>canvia amb la URL</i>"]
    style RP fill:#e0f2fe
    style PAG fill:#dcfce7

L'argument clau: el que hi ha per sobre de l'enrutador no es desmunta en navegar. Si ProveidorUsuari estigués dins d'una ruta, canviar de / a /estaciones desmuntaria el proveïdor, el seu useState es reiniciaria i l'usuari perdria la sessió a cada clic del menú. El mateix amb el tema: passaries de fosc a clar cada vegada que canviessis de pantalla.

En forma de taula, el criteri per decidir on col·locar cada proveïdor:

Situació On va
L'estat ha de sobreviure tota la vida de l'aplicació (sessió, tema) Fora de l'enrutador, a main.jsx
L'estat és del marc visual i el consumeixen diverses pantalles (avisos, reserves) A la ruta arrel (Disseny), 06-03
L'estat només interessa a una pantalla (terme de cerca del catàleg) Dins d'aquesta pàgina

I el LimitError global per sobre de tot té un motiu addicional: si el mateix enrutador llancés un error —una ruta mal configurada, un element que no és un element—, algú ho ha de capturar. A 06-03 veuràs que a més existeix errorElement, que actua dins de l'enrutador i per branca de ruta; els dos mecanismes conviuen i es reparteixen la feina.

  1. Enllaços: <Link> enfront de <a href>

Moment de saldar el deute del mòdul 2. Recordem la Capcalera tal com porta escrita des de llavors:

// src/components/Capcalera.jsx — la versió amb el deute
<nav>
  <a href="#cataleg">Catàleg</a>
  <a href="#estacions">Estacions</a>
  <a href="#reserves">Les meves reserves</a>
</nav>

La temptació és substituir #cataleg per /:

<a href="/">Catàleg</a>   {/* ❌ NO facis això en una SPA */}

I és un error, encara que «funcioni». Compara el que passa amb cadascun:

<a href="/estaciones"> <Link to="/estaciones">
Quin HTML produeix? <a href="/estaciones"> <a href="/estaciones"> (el mateix!)
En fer clic El navegador descarta l'aplicació i demana la pàgina al servidor Intercepta el clic, pushState i repinta
Recàrrega completa No
Estat de React Es perd tot: sessió, tema, reserves, avisos Intacte
Temps Centenars de mil·lisegons, amb pantalla en blanc Instantani
Clic amb Ctrl / botó central Obre en pestanya nova ✅ Obre en pestanya nova ✅
Cercadors i lectors de pantalla El veuen com un enllaç ✅ El veuen com un enllaç ✅

Aquell detall de les dues últimes files és important i sol sorprendre: <Link> renderitza un <a> real amb el seu href. No és un <div> amb un onClick, error habitual de qui es fabrica els seus propis enllaços. Per això conserva tot el bo d'un enllaç HTML —obrir en pestanya nova, copiar adreça, indexació, navegació per teclat, anunci correcte en un lector de pantalla, com exigia 03-06— i només canvia el comportament del clic simple, que intercepta amb preventDefault().

La regla és senzilla:

  • Navegació dins de l'aplicació → <Link to="…">.
  • Enllaç a un altre lloc web, a un PDF o a mailto:<a href="…"> de tota la vida. React Router no l'ha d'interceptar.
// Correcte: intern amb Link, extern amb a
<Link to="/estaciones">Estacions</Link>
<a href="https://www.ciclourbano.test/ayuda" target="_blank" rel="noreferrer">
  Centre d'ajuda
</a>

Un avís que estalvia hores de depuració: si en algun moment veus que en prémer un enllaç es perd la sessió o parpelleja tota la pantalla, gairebé segur que has deixat un <a href> intern sense convertir. És el fallo més repetit en enrutar una aplicació existent.

  1. <NavLink> i la classe activa: la nav de Capcalera

Per a un menú de navegació cal alguna cosa més: marcar visualment quina és la pantalla actual. <NavLink> és un <Link> que sap si la seva destinació coincideix amb la URL activa.

// src/components/Capcalera.jsx — el deute del mòdul 2, saldat
import { NavLink } from 'react-router';
import MenuUsuari from './MenuUsuari.jsx';
import estils from './Capcalera.module.css';

function Capcalera() {
  // Funció que decideix la classe segons l'estat de l'enllaç
  const classesEnllac = ({ isActive }) =>
    isActive ? `${estils.enllac} ${estils.actiu}` : estils.enllac;

  return (
    <header className={estils.capcalera}>
      <NavLink to="/" className={estils.marca}>
        CicloUrbano
      </NavLink>

      <nav aria-label="Navegació principal">
        <NavLink to="/" end className={classesEnllac}>
          Catàleg
        </NavLink>
        <NavLink to="/estaciones" className={classesEnllac}>
          Estacions
        </NavLink>
        <NavLink to="/reservas" className={classesEnllac}>
          Les meves reserves
        </NavLink>
      </nav>

      <MenuUsuari />
    </header>
  );
}

export default Capcalera;

Tres coses a explicar:

className pot ser una funció. NavLink la crida amb un objecte { isActive, isPending, isTransitioning } i fa servir el que retorni. És l'única prop de tot el projecte que es comporta així, i val la pena recordar per què: el component no sap quins noms de classe fa servir el teu CSS Module, així que et deixa decidir a tu. El mateix val per a style i per a children:

{/* children com a funció: útil per afegir una icona només a l'actiu */}
<NavLink to="/reservas">
  {({ isActive }) => (
    <>
      {isActive && <span aria-hidden="true">▸ </span>}
      Les meves reserves
    </>
  )}
</NavLink>

La prop end no és opcional a l'enllaç del catàleg. Per defecte, un NavLink es considera actiu si la URL comença per la seva destinació. Com que totes les rutes comencen per /, l'enllaç «Catàleg» apareixeria marcat sempre, fins i tot estant a /estaciones. end exigeix coincidència exacta i resol el problema:

URL actual <NavLink to="/"> <NavLink to="/" end> <NavLink to="/estaciones">
/ actiu actiu inactiu
/estaciones actiu ❌ inactiu actiu
/estaciones/est-02 actiu ❌ inactiu actiu (desitjable)

Fixa't en l'última fila: per a l'enllaç «Estacions» sí que volem el comportament per defecte, perquè estant al detall d'una estació té sentit que la secció continuï marcada. Per això end va només al primer.

Accessibilitat. Marcar l'enllaç actiu només amb color no n'hi ha prou (03-06). NavLink afegeix automàticament aria-current="page" a l'element actiu, que un lector de pantalla anuncia com «pàgina actual». Aprofita aquest atribut també al CSS, per no dependre d'una classe:

/* src/components/Capcalera.module.css */
.enllac {
  color: var(--color-text);
  text-decoration: none;
  padding: calc(var(--espai) / 2) var(--espai);
  border-radius: var(--radi);
}

.enllac:hover {
  background: var(--color-vora);
}

.actiu,
.enllac[aria-current='page'] {
  color: var(--color-marca);
  font-weight: 700;
  /* A més del color, un indicador no cromàtic */
  box-shadow: inset 0 -2px 0 var(--color-marca);
}

  1. Segments dinàmics i useParams

La ruta /bicicletas/:bicicletaId té un segment dinàmic: els dos punts indiquen que aquest tros és variable i el seu valor s'ha de capturar.

URL Coincideix amb /bicicletas/:bicicletaId? useParams() retorna
/bicicletas/bici-003 { bicicletaId: 'bici-003' }
/bicicletas/42 { bicicletaId: '42' }
/bicicletas No (falta el segment)
/bicicletas/bici-003/piezas No (sobra un segment)

Així es llegeix a la pàgina:

// src/pagines/PaginaFitxaBicicleta.jsx
import { useParams, Link } from 'react-router';
import { bicicletes, estacions } from '../dades/domini.js';
import EtiquetaEstat from '../components/EtiquetaEstat.jsx';
import PaginaNoTrobada from './PaginaNoTrobada.jsx';

function PaginaFitxaBicicleta() {
  const { bicicletaId } = useParams();

  const bicicleta = bicicletes.find((bici) => bici.id === bicicletaId);

  // El paràmetre pot ser qualsevol cosa: la URL l'escriu l'usuari
  if (!bicicleta) {
    return <PaginaNoTrobada recurs="bicicleta" identificador={bicicletaId} />;
  }

  const estacio = estacions.find((est) => est.id === bicicleta.estacioId);

  return (
    <article>
      <h2>{bicicleta.model}</h2>
      <EtiquetaEstat estat={bicicleta.estat} />
      <dl>
        <dt>Tipus</dt>
        <dd>{bicicleta.tipus}</dd>
        <dt>Estació</dt>
        <dd>
          <Link to={`/estaciones/${bicicleta.estacioId}`}>
            {estacio ? estacio.nom : 'Sense assignar'}
          </Link>
        </dd>
        <dt>Preu per hora</dt>
        <dd>{bicicleta.preuHora.toFixed(2)} €</dd>
      </dl>
      <Link to="/">Tornar al catàleg</Link>
    </article>
  );
}

export default PaginaFitxaBicicleta;

I l'enllaç que hi porta, des de TargetaBicicleta:

// src/components/TargetaBicicleta.jsx — fragment
<h3>
  <Link to={`/bicicletas/${bicicleta.id}`}>{bicicleta.model}</Link>
</h3>

La conversió de tipus és responsabilitat teva. Aquest és el punt que més fallades silencioses provoca:

Tots els paràmetres de ruta són cadenes de text. Sempre. La URL és text; React Router no pot saber si 42 és un número, un codi postal o un identificador.

A CicloUrbano no molesta, perquè els identificadors ja són cadenes (bici-003). Però si la teva API fes servir identificadors numèrics, això fallaria de manera desconcertant:

const { bicicletaId } = useParams();          // '3', una CADENA

bicicletes.find((b) => b.id === bicicletaId); // ❌ 3 === '3' és false → undefined
bicicletes.find((b) => b.id == bicicletaId);  // ⚠️ funciona, però == és mala idea
bicicletes.find((b) => b.id === Number(bicicletaId)); // ✅ conversió explícita

Converteix-ho com més aviat millor i en un sol lloc, i valida el resultat:

const { bicicletaId } = useParams();
const idNumeric = Number(bicicletaId);

if (!Number.isInteger(idNumeric) || idNumeric <= 0) {
  return <PaginaNoTrobada recurs="bicicleta" identificador={bicicletaId} />;
}

  1. Què fer quan l'identificador no existeix

Qualsevol pot escriure /bicicletas/bici-999 a la barra d'adreces, o arribar des d'un enllaç antic a una bicicleta donada de baixa. Tens tres estratègies, i triar bé importa:

Estratègia Com Quan convé
Pintar el «no trobat» al seu lloc if (!bicicleta) return <PaginaNoTrobada … /> Recomanada. La URL es conserva, l'usuari pot corregir-la i l'enllaç continua sent compartible per depurar
Redirigir al llistat <Navigate to="/" replace /> Quan el detall no té sentit sense context i el llistat és una alternativa útil. Es veu a 06-04
Llançar i deixar que ho capturi l'enrutador throw new Response('No trobada', { status: 404 }) + errorElement Aplicacions grans amb gestió d'errors centralitzada. Es veu a 06-03

Amplia PaginaNoTrobada perquè serveixi als dos usos —URL inexistent i recurs inexistent— amb un missatge afinat:

// src/pagines/PaginaNoTrobada.jsx
import { Link, useLocation } from 'react-router';
import Avis from '../components/Avis.jsx';

/**
 * Pantalla de «no trobat» de CicloUrbano.
 * Props:
 *  - recurs         (cadena, opcional): 'bicicleta', 'estació'… Si falta, és una URL desconeguda
 *  - identificador   (cadena, opcional): l'id que no s'ha trobat
 */
function PaginaNoTrobada({ recurs, identificador }) {
  const { pathname } = useLocation();

  const titol = recurs
    ? `No existeix cap ${recurs} amb l'identificador «${identificador}»`
    : 'Aquesta pàgina no existeix';

  return (
    <section>
      <Avis to="error" titol={titol}>
        <p>
          {recurs
            ? 'Pot ser que s\'hagi donat de baixa o que l\'enllaç estigui desactualitzat.'
            : `L'adreça ${pathname} no correspon a cap pantalla de CicloUrbano.`}
        </p>
      </Avis>
      <p>
        <Link to="/">Anar al catàleg</Link> · <Link to="/estaciones">Veure estacions</Link>
      </p>
    </section>
  );
}

export default PaginaNoTrobada;

Una precisió honesta que convé conèixer: aquesta pantalla no retorna un codi HTTP 404. El servidor ha lliurat l'index.html amb estat 200 i el 404 és només visual. Perquè un cercador rebi el codi correcte cal renderitzat en servidor (10-01). En una aplicació interna com el panell de CicloUrbano no és un problema; en una web pública, sí.

  1. Paràmetres de consulta amb useSearchParams

Torna a PaginaCataleg. El filtre per tipus viu avui en un useState, i això té el mateix defecte que denunciàvem a 06-01: si un operari filtra per electrica i passa l'adreça a un company, aquest veu el catàleg sense filtrar. El filtre és informació sobre el que l'usuari està mirant: pertany a la URL.

useSearchParams és als paràmetres de consulta el que useState és a l'estat local, amb una signatura deliberadament semblant:

const [parametres, establirParametres] = useSearchParams();

Amb la diferència que parametres és un objecte URLSearchParams estàndard del navegador, no un objecte pla. Els seus mètodes:

Mètode Què fa
parametres.get('tipo') Retorna el valor, o null si no hi és
parametres.getAll('tipo') Tots els valors, si la clau es repeteix
parametres.has('tipo') true / false
parametres.set('tipo', 'carga') Fixa el valor (sobre una còpia, vegeu més avall)
parametres.delete('tipo') Elimina la clau
parametres.toString() "tipo=carga&orden=precio"

Així queda el catàleg:

// src/pagines/PaginaCataleg.jsx — amb el filtre a la URL
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import CercadorBicicletes from '../components/CercadorBicicletes.jsx';
import SelectorTipus from '../components/SelectorTipus.jsx';
import LlistaBicicletes from '../components/LlistaBicicletes.jsx';
import { bicicletes, estacions } from '../dades/domini.js';

function PaginaCataleg() {
  const [terme, setTerme] = useState('');
  const [parametres, establirParametres] = useSearchParams();

  // La URL mana: si no hi ha ?tipo=, el filtre és 'todos'
  const tipusTriat = parametres.get('tipo') ?? 'todos';

  function gestionarCanviDeTipus(nouTipus) {
    // Es parteix SEMPRE dels paràmetres actuals per no perdre la resta
    const seguents = new URLSearchParams(parametres);

    if (nouTipus === 'todos') {
      seguents.delete('tipo');   // sense filtre, URL neta: '/' i no '/?tipo=todos'
    } else {
      seguents.set('tipo', nouTipus);
    }

    // replace: canviar de filtre no hauria d'omplir l'historial d'entrades
    establirParametres(seguents, { replace: true });
  }

  const visibles = bicicletes
    .filter((bici) => tipusTriat === 'todos' || bici.tipus === tipusTriat)
    .filter((bici) => bici.model.toLowerCase().includes(terme.toLowerCase()));

  return (
    <section>
      <h2>Catàleg de bicicletes</h2>
      <CercadorBicicletes terme={terme} alCanviarTerme={setTerme} />
      <SelectorTipus tipusTriat={tipusTriat} alCanviarTipus={gestionarCanviDeTipus} />
      {visibles.length === 0 ? (
        <AvisSenseResultats terme={terme} tipus={tipusTriat} />
      ) : (
        <LlistaBicicletes bicicletes={visibles} estacions={estacions} />
      )}
    </section>
  );
}

export default PaginaCataleg;

L'essencial d'aquest codi:

  • SelectorTipus no s'ha tocat. Continua sent el component controlat de 03-04, amb els seus props tipusTriat i alCanviarTipus. L'única cosa que ha canviat és d'on surt el valor i on va el canvi. Això és exactament el que buscàvem en mantenir els components desacoblats de l'enrutador (apartat 3).
  • Es copia abans de modificar: new URLSearchParams(parametres). Mutar l'objecte que retorna el hook no repinta res i pot provocar incoherències, per la mateixa raó d'immutabilitat de 05-01.
  • ?tipo=todos s'elimina en comptes d'escriure's. Un valor per defecte no ha d'embrutar la URL; / i /?tipo=todos mostrarien el mateix i són dues adreces diferents, dolent per compartir i per a l'analítica.
  • { replace: true } evita que cada clic al filtre afegeixi una entrada a l'historial. Si un usuari prova cinc tipus, no voldrà prémer «enrere» cinc vegades per sortir del catàleg. És un judici de disseny: per a una paginació sol preferir-se deixar rastre.
  • El terme de cerca continua en useState. Decisió deliberada: es tecleja lletra a lletra i portar-lo a la URL generaria desenes d'entrades o exigiria combinar-lo amb l'useDebounce de 05-06. Quan el cercador hagi de ser compartible, el patró serà useSearchParams + useDebounce.

Què es guanya exactament en convertir estat de React en estat de la URL:

Abans (useState) Després (useSearchParams)
/ sempre, filtri el que filtri /?tipo=electrica
Compartir l'enllaç perd el filtre El company veu exactament el mateix
F5 torna al catàleg sense filtrar F5 conserva el filtre
«Enrere» surt de l'aplicació «Enrere» torna al filtre anterior (si no fas servir replace)
L'analítica no distingeix filtres Es pot mesurar quin tipus es consulta més
L'estat inicial cal inventar-lo El dicta la URL

I la contrapartida honesta: la URL és pública i compartible, així que hi ha d'anar només allò que no importi que es vegi i es desi. Filtres, ordenacions, número de pàgina, pestanya activa: sí. Dades personals, contingut d'un formulari o qualsevol secret: mai.

  1. La ruta comodí i la pàgina de no trobat

L'última entrada del mapa és:

{ path: '*', element: <PaginaNoTrobada /> }

L'asterisc és la ruta comodí (splat route): coincideix amb qualsevol URL que cap altra ruta hagi reclamat. El seu paper és que un error tipogràfic produeixi una pantalla útil en lloc d'un buit. Sense ella, /estacionez renderitzaria absolutament res: pantalla en blanc, sense cap error a la consola. És un fallo desconcertant i molt comú oblidar aquesta línia.

El valor capturat pel comodí es llegeix amb la clau '*', útil si vols mostrar-lo o registrar-lo:

const parametres = useParams();
console.log(parametres['*']); // 'estacionez/algo/mas'

Un comodí també serveix en un punt intermedi, no només a l'arrel. { path: '/documentacion/*' } captura qualsevol profunditat sota aquest prefix, patró habitual en integrar contingut extern. A CicloUrbano no cal.

  1. Com tria React Router la millor coincidència

Pregunta natural: si la URL és /reservas/nueva, per què no coincideix amb /reservas o amb *? La resposta és una de les millors decisions de disseny de React Router des de la v6:

React Router puntua totes les rutes i tria la més específica, sense importar en quin ordre les hagis escrit a l'array.

El criteri de puntuació, simplificat:

Tipus de segment Puntuació Exemple
Segment literal Alta estaciones
Segment dinàmic Mitjana :estacionId
Comodí * Mínima *

Aplicat a /estaciones/est-02, amb el mapa complet:

Ruta candidata Coincideix? Puntuació Resultat
/estaciones/:estacionId literal + dinàmic → alta Guanya
/estaciones No, sobra un segment Descartada
* mínima Perdedora

Compara-ho amb React Router v5 i anteriors, on <Switch> prenia la primera ruta que coincidís en ordre d'escriptura. Allò obligava a ordenar l'array de més específic a més general, a fer servir exact per tot arreu i produïa errors molt difícils de veure: una ruta col·locada dues línies més amunt del compte se «menjava» totes les altres. Avui pots escriure el mapa en l'ordre que et resulti llegible.

Dos matisos que convé retenir:

  • La coincidència ha de ser completa. /estaciones no coincideix amb la URL /estaciones/est-02 a menys que tingui rutes filles que absorbeixin la resta (06-03) o acabi en *.
  • end a NavLink és una altra cosa. Governa només la classe activa de l'enllaç, no quina ruta es pinta. És normal confondre'ls al principi.
flowchart TD
    URL["URL: /estaciones/est-02"] --> CAND{"Rutes candidates"}
    CAND --> R1["/estaciones/:estacionId<br/>literal + dinàmic"]
    CAND --> R2["/estaciones<br/>❌ no cobreix tota la URL"]
    CAND --> R3["*<br/>puntuació mínima"]
    R1 --> GANA["✅ Es renderitza<br/>PaginaDetallEstacio<br/>params: { estacionId: 'est-02' }"]
    style GANA fill:#dcfce7
    style R2 fill:#fecaca

Errors Comuns i Consells

Passar el component en lloc de l'element. { path: '/', element: PaginaCataleg } en lloc de element: <PaginaCataleg />. No llança un error clar: simplement no es pinta res. Recorda que element rep JSX ja creat.

Deixar <a href> interns sense convertir. El símptoma és inconfusible: en prémer un enllaç, la pantalla parpelleja i es perden la sessió, el tema i les reserves. Busca href="/ al teu projecte i converteix tot el que sigui intern a <Link>.

Oblidar end al NavLink de l'inici. L'enllaç «Catàleg» queda marcat com a actiu a totes les pantalles. El mateix passa amb qualsevol NavLink la destinació del qual sigui prefix d'altres rutes.

Cridar createBrowserRouter dins d'un component. Cada render crearia un enrutador nou, i l'aplicació tornaria a la ruta inicial tan bon punt alguna cosa repintés. El mapa es crea una vegada, en l'àmbit del mòdul.

Mutar l'objecte de useSearchParams. parametres.set('tipo', 'carga') sobre l'objecte que retorna el hook no provoca cap repintat. Copia sempre: new URLSearchParams(parametres).

Posar un ? de més en construir la URL. establirParametres('?tipo=carga') produeix ??tipo=carga. Passa un URLSearchParams o un objecte pla i deixa que React Router posi el separador.

Esperar un número a useParams. Tot és cadena. Converteix i valida, en un sol lloc i com més aviat millor.

Oblidar la ruta *. Qualsevol error tipogràfic produeix una pantalla en blanc silenciosa, sense error a la consola. És dels primers fallos que un usuari reporta.

Consell: anomena els paràmetres igual a la ruta i al codi. path: '/bicicletas/:bicicletaId' i const { bicicletaId } = useParams(). Si la ruta diu :id i desestructures bicicletaId, obtindràs undefined sense cap avís.

Consell: mantén una única font de veritat per dada. Si el tipus triat viu a la URL, no el duplicis en un useState «per tenir-lo a mà». Duplicar-lo garanteix que tard o d'hora es desincronitzaran, exactament com advertia 05-01 sobre l'estat derivat.

Consell: comprova la recàrrega a cada ruta nova. Navegar amb enllaços sempre funciona; recarregar és el que descobreix els problemes de configuració del servidor. Amb Vite en desenvolupament no veuràs el fallo, però convé tenir present el que hem vist a 06-01 sobre la reescriptura a index.html.

Exercicis

Exercici 1: la ruta de detall d'estació

Afegeix a CicloUrbano la pantalla de detall d'estació, /estaciones/:estacionId. Ha de:

  1. Llegir el paràmetre i buscar l'estació a estacions.
  2. Mostrar nom, barri i places.
  3. Llistar les bicicletes l'estacioId de les quals hi coincideixi, cadascuna enllaçant a la seva fitxa.
  4. Mostrar el «no trobat» si l'identificador no existeix.
  5. Incloure un enllaç de tornada a /estaciones.

Afegeix a més l'enllaç des de TargetaEstacio per arribar-hi.

Exercici 2: ordenar el llistat des de la URL

A PaginaEstacions, afegeix un desplegable que permeti ordenar les estacions per nom o per places, desant la tria en un paràmetre de consulta orden. Requisits:

  • La URL ha de quedar /estaciones?orden=plazas.
  • El valor per defecte (nombre) no ha d'aparèixer a la URL.
  • No s'han de perdre altres paràmetres de consulta que pogués haver-hi.
  • Un valor invàlid escrit a mà (?orden=inventado) no ha de trencar la pantalla.

Exercici 3: detectar els fallos

Aquest mapa de rutes i aquesta capçalera tenen cinc errors. Troba'ls i corregeix-los.

// src/rutes.jsx
import { createBrowserRouter } from 'react-router';
import PaginaCataleg from './pagines/PaginaCataleg.jsx';
import PaginaFitxaBicicleta from './pagines/PaginaFitxaBicicleta.jsx';
import PaginaEstacions from './pagines/PaginaEstacions.jsx';

function crearRouter() {
  return createBrowserRouter([
    { path: '/', element: PaginaCataleg },
    { path: '/bicicletas/:id', element: <PaginaFitxaBicicleta /> },
    { path: '/estaciones', element: <PaginaEstacions /> }
  ]);
}

export default crearRouter;
// src/components/Capcalera.jsx
import { NavLink } from 'react-router';

function Capcalera() {
  return (
    <nav>
      <NavLink to="/" className={({ isActive }) => (isActive ? 'actiu' : '')}>
        Catàleg
      </NavLink>
      <a href="/estaciones">Estacions</a>
    </nav>
  );
}
// src/pagines/PaginaFitxaBicicleta.jsx — fragment
const { bicicletaId } = useParams();
const bicicleta = bicicletes.find((bici) => bici.id === bicicletaId);
return <h2>{bicicleta.model}</h2>;

Solucions

Solució 1

// src/pagines/PaginaDetallEstacio.jsx
import { useParams, Link } from 'react-router';
import { estacions, bicicletes } from '../dades/domini.js';
import EtiquetaEstat from '../components/EtiquetaEstat.jsx';
import PaginaNoTrobada from './PaginaNoTrobada.jsx';

function PaginaDetallEstacio() {
  const { estacionId } = useParams();
  const estacio = estacions.find((est) => est.id === estacionId);

  if (!estacio) {
    return <PaginaNoTrobada recurs="estació" identificador={estacionId} />;
  }

  const flota = bicicletes.filter((bici) => bici.estacioId === estacio.id);

  return (
    <article>
      <h2>{estacio.nom}</h2>
      <p>
        Barri de {estacio.barri} · {estacio.places} places ·{' '}
        {flota.length} bicicletes assignades
      </p>

      <h3>Flota de l'estació</h3>
      {flota.length === 0 ? (
        <p>No hi ha bicicletes assignades a aquesta estació.</p>
      ) : (
        <ul>
          {flota.map((bici) => (
            <li key={bici.id}>
              <Link to={`/bicicletas/${bici.id}`}>{bici.model}</Link>{' '}
              <EtiquetaEstat estat={bici.estat} />
            </li>
          ))}
        </ul>
      )}

      <Link to="/estaciones">Tornar a les estacions</Link>
    </article>
  );
}

export default PaginaDetallEstacio;
// src/components/TargetaEstacio.jsx — fragment amb l'enllaç afegit
<h3>
  <Link to={`/estaciones/${estacio.id}`}>{estacio.nom}</Link>
</h3>

Amb la ruta ja present a rutes.jsx, no hi ha res més a registrar. I observa que /estaciones i /estaciones/est-02 conviuen sense problema gràcies a la puntuació de l'apartat 13: l'ordre a l'array és irrellevant.

Solució 2

// src/pagines/PaginaEstacions.jsx
import { useSearchParams } from 'react-router';
import TargetaEstacio from '../components/TargetaEstacio.jsx';
import { estacions, bicicletes } from '../dades/domini.js';

const ORDRES_VALIDS = ['nombre', 'plazas'];
const ORDRE_PER_DEFECTE = 'nombre';

function PaginaEstacions() {
  const [parametres, establirParametres] = useSearchParams();

  // Validació: un valor inventat a la URL cau al valor per defecte
  const ordreBrut = parametres.get('orden');
  const ordre = ORDRES_VALIDS.includes(ordreBrut) ? ordreBrut : ORDRE_PER_DEFECTE;

  function gestionarCanviDeOrdre(esdeveniment) {
    const nou = esdeveniment.target.value;
    const seguents = new URLSearchParams(parametres); // còpia: conserva la resta

    if (nou === ORDRE_PER_DEFECTE) {
      seguents.delete('orden');   // el valor per defecte no embruta la URL
    } else {
      seguents.set('orden', nou);
    }

    establirParametres(seguents, { replace: true });
  }

  // Còpia abans d'ordenar: sort() muta l'array original (05-01)
  const ordenades = [...estacions].sort((a, b) =>
    ordre === 'plazas' ? b.places - a.places : a.nom.localeCompare(b.nom)
  );

  return (
    <section>
      <h2>Estacions</h2>

      <p>
        <label htmlFor="orden-estaciones">Ordenar per</label>{' '}
        <select id="orden-estaciones" value={ordre} onChange={gestionarCanviDeOrdre}>
          <option value="nom">Nom (A-Z)</option>
          <option value="places">Places (més primer)</option>
        </select>
      </p>

      <ul>
        {ordenades.map((estacio) => (
          <li key={estacio.id}>
            <TargetaEstacio
              estacio={estacio}
              bicicletesEnEstacio={bicicletes.filter((b) => b.estacioId === estacio.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

export default PaginaEstacions;

Els quatre requisits queden coberts per: la llista blanca ORDRES_VALIDS (validació), el delete quan el valor és el de per defecte (URL neta), la còpia new URLSearchParams(parametres) (no perdre altres paràmetres) i el [...estacions] abans de sort (no mutar les dades originals). El <select> és un component controlat de 03-04, amb la particularitat que el seu valor surt de la URL, no d'un useState.

Solució 3

Els cinc errors:

  1. element: PaginaCataleg passa el component, no l'element. Ha de ser element: <PaginaCataleg />. Símptoma: pantalla en blanc sense error clar.
  2. createBrowserRouter dins d'una funció que s'exporta com crearRouter. Si main.jsx la crida a cada render, es crea un enrutador nou cada vegada. S'ha de crear una sola vegada en l'àmbit del mòdul i exportar-se amb nom: export const router = createBrowserRouter([...]);.
  3. Falta la ruta comodí { path: '*', element: <PaginaNoTrobada /> }. Qualsevol URL desconeguda deixa la pantalla buida en silenci.
  4. Desajust de noms: la ruta declara :id però la pàgina desestructura bicicletaId, que serà undefined. Han de coincidir; el correcte segons el mapa del mòdul és :bicicletaId als dos llocs.
  5. A Capcalera, <a href="/estaciones"> provoca una recàrrega completa i perd tot l'estat de React. Ha de ser <NavLink to="/estaciones">.

I un sisè fallo, latent, que convé assenyalar: a PaginaFitxaBicicleta no es comprova si bicicleta existeix, així que /bicicletas/bici-999 rebentarà amb «Cannot read properties of undefined (reading 'model')». Afegeix la guarda de l'apartat 10. Nota, a més, que aquest error sí que el capturaria el LimitError global de 04-05, però tombant l'aplicació sencera per una cosa que és simplement una URL mal escrita: gestionar-lo al seu lloc és molt millor experiència.

Conclusió

CicloUrbano ja està enrutada. Has instal·lat react-router —el paquet de la v7, sabent que react-router-dom és el que veuràs en projectes de la v6—, has creat src/rutes.jsx amb el mapa complet com a array d'objectes i has muntat <RouterProvider router={router} /> a main.jsx conservant l'ordre StrictMode > LimitError > ProveidorTema > ProveidorUsuari, perquè el que hi ha per sobre de l'enrutador no es desmunta en navegar i per això la sessió i el tema sobreviuen als canvis de pantalla. El projecte té una carpeta src/pagines/ amb una pantalla per ruta i una regla clara: les pàgines poden dependre de l'enrutador; els components reutilitzables reben les seves dades per props i continuen sent provables per separat.

Pel camí has saldat el deute que arrossegava la Capcalera des del mòdul 2: els tres <a href="#…"> són ara <NavLink> amb classe activa, aria-current="page" gratuïtament i la prop end a l'enllaç del catàleg perquè no aparegui marcat a totes les pantalles. Saps que <Link> produeix un <a> real —amb tot el bo d'un enllaç HTML— i només intercepta el clic simple, mentre que un <a href> intern destrueix l'aplicació i s'emporta per davant sessió, tema i reserves. Has llegit l'identificador de la fitxa amb useParams, recordant que tot paràmetre de ruta és una cadena i que la conversió i la validació són teves, i has decidit què fer quan el recurs no existeix. I has convertit el filtre del SelectorTipus d'estat de React a estat de la URL amb useSearchParams, guanyant enllaços compartibles, recàrrega que conserva el filtre i analítica útil, sense tocar ni una línia del component controlat. Tanquen el quadre la ruta comodí * —l'absència de la qual produeix pantalles en blanc silencioses— i el sistema de puntuació que tria la coincidència més específica sense dependre de l'ordre de l'array.

Queda un problema visible: les pantalles es pinten soles, sense capçalera ni peu, perquè Disseny s'ha quedat fora del mapa. Reintroduir-lo com un component que embolcalla cada pàgina funcionaria, però tornaria a muntar la capçalera a cada navegació i no permetria compartir dades entre marc i pantalla. La solució correcta és fer de Disseny la ruta pare de totes les altres i obrir-hi un forat on pintar la filla activa; amb la mateixa idea construiràs les pestanyes flota i incidencias dins del detall d'estació, les molles de pa i la gestió d'errors per branca. La propera lliçó és Rutes Imbricades.

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