En la lección anterior quedó claro qué es el enrutamiento en el cliente, por qué React no lo trae de serie y cuál de las tres formas de usar React Router v7 emplea este curso: el modo de datos, con createBrowserRouter y RouterProvider. Ahora toca escribirlo. En esta lección conviertes CicloUrbano de una aplicación de una sola pantalla en una aplicación enrutada: instalarás el paquete, crearás el mapa de rutas en src/rutas.jsx, montarás el proveedor del enrutador en main.jsx sin romper el orden de proveedores que ya existe, reorganizarás el proyecto con una carpeta src/paginas/, sustituirás los <a href="#…"> de la Cabecera por enlaces de verdad —saldando por fin la deuda del módulo 2—, leerás el identificador de la ficha con useParams y trasladarás el filtro del SelectorTipo desde useState hasta la propia URL con useSearchParams. Al terminar, cada pantalla de CicloUrbano tendrá su dirección.

Contenido

  1. Punto de partida y plan de la reorganización
  2. Instalación del paquete
  3. La carpeta src/paginas/ y qué se queda en src/componentes/
  4. El mapa de rutas: src/rutas.jsx
  5. Montar el enrutador en main.jsx
  6. Por qué los proveedores van fuera del enrutador
  7. Enlaces: <Link> frente a <a href>
  8. <NavLink> y la clase activa: la nav de Cabecera
  9. Segmentos dinámicos y useParams
  10. Qué hacer cuando el identificador no existe
  11. Parámetros de consulta con useSearchParams
  12. La ruta comodín y la página de no encontrado
  13. Cómo elige React Router la mejor coincidencia

  1. Punto de partida y plan de la reorganización

Así está hoy App.jsx, al final del módulo 5: una única pantalla que muestra el catálogo con su buscador, su filtro y su formulario de reserva, todo envuelto en Diseno.

// src/App.jsx — el punto de partida (versión resumida)
import Diseno from './componentes/Diseno.jsx';
import BuscadorBicicletas from './componentes/BuscadorBicicletas.jsx';
import SelectorTipo from './componentes/SelectorTipo.jsx';
import ListaBicicletas from './componentes/ListaBicicletas.jsx';
import PanelReserva from './componentes/PanelReserva.jsx';
import { useState } from 'react';
import { bicicletas, estaciones } from './datos/dominio.js';

function App() {
  const [termino, setTermino] = useState('');
  const [tipoElegido, setTipoElegido] = useState('todos');
  const [idSeleccionada, setIdSeleccionada] = useState(null);

  const visibles = bicicletas
    .filter((bici) => tipoElegido === 'todos' || bici.tipo === tipoElegido)
    .filter((bici) => bici.modelo.toLowerCase().includes(termino.toLowerCase()));

  return (
    <Diseno>
      <BuscadorBicicletas termino={termino} alCambiarTermino={setTermino} />
      <SelectorTipo tipoElegido={tipoElegido} alCambiarTipo={setTipoElegido} />
      <ListaBicicletas
        bicicletas={visibles}
        estaciones={estaciones}
        idSeleccionada={idSeleccionada}
        alSeleccionar={setIdSeleccionada}
      />
      <PanelReserva />
    </Diseno>
  );
}

export default App;

El plan de esta lección, en orden:

flowchart LR
    A["1. npm install<br/>react-router"] --> B["2. Crear src/paginas/<br/>con una pantalla por ruta"]
    B --> C["3. src/rutas.jsx<br/>createBrowserRouter"]
    C --> D["4. main.jsx<br/>RouterProvider"]
    D --> E["5. Cabecera<br/>NavLink"]
    E --> F["6. useParams<br/>useSearchParams"]

Al terminar, App.jsx desaparece como pantalla: su contenido pasa a PaginaCatalogo y el papel de raíz lo asume el enrutador. Es la reestructuración más grande del curso hasta ahora, así que conviene hacerla por pasos y comprobar que la aplicación arranca en cada uno.

  1. Instalación del paquete

npm install react-router

Eso es todo. Ni complementos de Vite, ni configuración adicional: en el modo de datos, React Router es una dependencia normal.

Sobre el nombre del paquete, porque es la primera confusión con la que te vas a encontrar:

Paquete Versión Qué es
react-router-dom v6 y anteriores El paquete que se instalaba en aplicaciones web. Exportaba BrowserRouter, Link, etc.
react-router v6 y anteriores El núcleo independiente de plataforma. No se instalaba directamente
react-router v7 El único paquete que necesitas. De aquí sale todo
react-router-dom v7 Sigue publicándose, pero solo reexporta react-router. Existe para no romper proyectos migrados

Consecuencia práctica: todos los ejemplos de internet que veas con from 'react-router-dom' funcionan igual cambiando el importe a from 'react-router'. Elige uno y sé coherente; tener los dos instalados es una fuente de errores de contexto muy difíciles de diagnosticar, porque puedes acabar con dos copias del enrutador en memoria.

Comprueba la versión instalada:

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

  1. La carpeta src/paginas/ y qué se queda en src/componentes/

Antes de escribir el mapa hay que decidir dónde vive cada cosa. Introducimos una carpeta nueva:

src/
├── componentes/     ← piezas reutilizables de interfaz
├── contextos/
├── datos/
├── hooks/
├── paginas/         ← NUEVO: una pantalla por ruta
├── reductores/
├── utilidades/
├── main.jsx
└── rutas.jsx        ← NUEVO: el mapa de rutas

La regla que separa ambas carpetas:

src/paginas/ src/componentes/
Qué es La pantalla completa asociada a una ruta Una pieza de interfaz reutilizable
Quién lo renderiza El enrutador, y solo él Otros componentes o páginas
Cuántas veces aparece Una, en su ruta Las que haga falta
Puede leer useParams Sí, es su sitio natural Preferiblemente no: recíbelo por props
Ejemplos PaginaCatalogo, PaginaAcceso TarjetaBicicleta, Aviso, Modal

Ese último punto es más que una convención de orden: una página puede depender del enrutador; un componente reutilizable, cuanto menos, mejor. Si TarjetaBicicleta llama a useParams() por dentro, deja de poder usarse en una pantalla que no tenga ese parámetro, y probarla en el módulo 9 obligará a envolverla en un enrutador falso. La página lee el parámetro y se lo pasa por props: el componente sigue siendo una función de sus props, como en 02-03.

Estas son las páginas de CicloUrbano. Empezamos con versiones mínimas y las iremos completando:

// src/paginas/PaginaCatalogo.jsx
import { useState } from 'react';
import BuscadorBicicletas from '../componentes/BuscadorBicicletas.jsx';
import SelectorTipo from '../componentes/SelectorTipo.jsx';
import ListaBicicletas from '../componentes/ListaBicicletas.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';

function PaginaCatalogo() {
  const [termino, setTermino] = useState('');
  const [tipoElegido, setTipoElegido] = useState('todos');

  const visibles = bicicletas
    .filter((bici) => tipoElegido === 'todos' || bici.tipo === tipoElegido)
    .filter((bici) => bici.modelo.toLowerCase().includes(termino.toLowerCase()));

  return (
    <section>
      <h2>Catálogo de bicicletas</h2>
      <BuscadorBicicletas termino={termino} alCambiarTermino={setTermino} />
      <SelectorTipo tipoElegido={tipoElegido} alCambiarTipo={setTipoElegido} />
      <ListaBicicletas bicicletas={visibles} estaciones={estaciones} />
    </section>
  );
}

export default PaginaCatalogo;
// src/paginas/PaginaEstaciones.jsx
import TarjetaEstacion from '../componentes/TarjetaEstacion.jsx';
import { estaciones, bicicletas } from '../datos/dominio.js';

function PaginaEstaciones() {
  return (
    <section>
      <h2>Estaciones</h2>
      <ul>
        {estaciones.map((estacion) => (
          <li key={estacion.id}>
            <TarjetaEstacion
              estacion={estacion}
              bicicletasEnEstacion={bicicletas.filter((b) => b.estacionId === estacion.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

export default PaginaEstaciones;
// src/paginas/PaginaReservas.jsx
import { useReservas } from '../contextos/ContextoReservas.jsx';
import PanelReservas from '../componentes/PanelReservas.jsx';

function PaginaReservas() {
  const { estado } = useReservas();

  return (
    <section>
      <h2>Mis reservas</h2>
      <PanelReservas reservas={estado.reservas} />
    </section>
  );
}

export default PaginaReservas;

PaginaAcceso la desarrollarás en 06-05 (necesita iniciarSesion y la vuelta al destino original), así que de momento basta con un esqueleto. Y falta la más importante para esta lección:

// src/paginas/PaginaNoEncontrada.jsx
import { Link } from 'react-router';

function PaginaNoEncontrada() {
  return (
    <section>
      <h2>Esta página no existe</h2>
      <p>
        La dirección que has escrito no corresponde a ninguna pantalla de CicloUrbano.
        Puede que el enlace esté anticuado o que haya un error tipográfico.
      </p>
      <Link to="/">Volver al catálogo</Link>
    </section>
  );
}

export default PaginaNoEncontrada;

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

Aquí está el corazón del modo de datos: el mapa es un array de objetos JavaScript, no marcado JSX.

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

import PaginaCatalogo from './paginas/PaginaCatalogo.jsx';
import PaginaFichaBicicleta from './paginas/PaginaFichaBicicleta.jsx';
import PaginaEstaciones from './paginas/PaginaEstaciones.jsx';
import PaginaDetalleEstacion from './paginas/PaginaDetalleEstacion.jsx';
import PaginaReservas from './paginas/PaginaReservas.jsx';
import PaginaNuevaReserva from './paginas/PaginaNuevaReserva.jsx';
import PaginaAcceso from './paginas/PaginaAcceso.jsx';
import PaginaTaller from './paginas/PaginaTaller.jsx';
import PaginaNoEncontrada from './paginas/PaginaNoEncontrada.jsx';

export const router = createBrowserRouter([
  { path: '/', element: <PaginaCatalogo /> },
  { path: '/bicicletas/:bicicletaId', element: <PaginaFichaBicicleta /> },
  { path: '/estaciones', element: <PaginaEstaciones /> },
  { path: '/estaciones/:estacionId', element: <PaginaDetalleEstacion /> },
  { path: '/reservas', element: <PaginaReservas /> },
  { path: '/reservas/nueva', element: <PaginaNuevaReserva /> },
  { path: '/acceso', element: <PaginaAcceso /> },
  { path: '/taller', element: <PaginaTaller /> },
  { path: '*', element: <PaginaNoEncontrada /> }
]);

Desmenucemos las decisiones:

  • El fichero es .jsx, no .js. Contiene JSX en la propiedad element, así que la extensión debe permitirlo o Vite se quejará.
  • path es el patrón de URL, con / inicial en las rutas de primer nivel. Los segmentos que empiezan por : son dinámicos.
  • element recibe un elemento de React ya creado (<PaginaCatalogo />), no el componente (PaginaCatalogo). Es una diferencia sutil pero estricta: pasar el componente sin instanciar es un error clásico y produce una pantalla en blanco.
  • Se exporta router con nombre, siguiendo la convención del proyecto: export default para componentes, exportación nombrada para todo lo demás.
  • createBrowserRouter se llama una sola vez, fuera de cualquier componente. Si lo llamases dentro de un componente, cada render crearía un enrutador nuevo y perderías todo el estado de navegación.
  • La ruta * va al final por legibilidad, no por necesidad: el orden ya no decide nada (apartado 13).

Todavía no hay anidamiento. Diseno aún no está en el mapa, así que las pantallas se pintan sueltas, sin cabecera ni pie. Es un estado intermedio deliberado: lo arreglarás en 06-03, que es justo la lección de rutas anidadas.

  1. Montar el enrutador en main.jsx

Recuerda cómo estaba el punto de entrada al final del módulo 5:

// src/main.jsx — ANTES
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import LimiteDeError from './componentes/LimiteDeError.jsx';
import { ProveedorTema } from './contextos/ContextoTema.jsx';
import { ProveedorUsuario } from './contextos/ContextoUsuario.jsx';
import { registrarError } from './utilidades/monitorizacion.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LimiteDeError titulo="CicloUrbano no está disponible ahora mismo" alRegistrar={registrarError}>
      <ProveedorTema>
        <ProveedorUsuario>
          <App />
        </ProveedorUsuario>
      </ProveedorTema>
    </LimiteDeError>
  </StrictMode>
);

El cambio consiste en sustituir <App /> por <RouterProvider router={router} />:

// src/main.jsx — DESPUÉS
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { router } from './rutas.jsx';
import LimiteDeError from './componentes/LimiteDeError.jsx';
import { ProveedorTema } from './contextos/ContextoTema.jsx';
import { ProveedorUsuario } from './contextos/ContextoUsuario.jsx';
import { registrarError } from './utilidades/monitorizacion.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LimiteDeError titulo="CicloUrbano no está disponible ahora mismo" alRegistrar={registrarError}>
      <ProveedorTema>
        <ProveedorUsuario>
          <RouterProvider router={router} />
        </ProveedorUsuario>
      </ProveedorTema>
    </LimiteDeError>
  </StrictMode>
);

Puntos que merecen comentario:

  • RouterProvider no acepta children. Todo lo que se pinta debajo sale del mapa de rutas. Si escribes <RouterProvider router={router}><Algo /></RouterProvider>, ese <Algo /> no aparecerá nunca.
  • App.jsx ya no se importa. Puedes borrarlo o dejarlo vacío; su papel lo cumplen ahora rutas.jsx (qué se pinta) y Diseno (cómo se enmarca, a partir de 06-03).
  • StrictMode sigue en la raíz, como desde 01-03. Seguirá provocando el doble montaje en desarrollo, y eso incluye los efectos de las páginas: nada nuevo respecto a 05-02.
  • ProveedorReservas y ProveedorAvisos no aparecen aquí. En 06-03 los colocarás dentro de Diseno, la ruta raíz, junto al resto del marco de la aplicación. También podrías subirlos a main.jsx; la decisión se justifica en la lección siguiente.

Con esto ya puedes arrancar npm run dev, escribir http://localhost:5173/estaciones en la barra de direcciones y ver el listado de estaciones. Es el primer momento del curso en que la URL significa algo.

  1. Por qué los proveedores van fuera del enrutador

El orden StrictMode > LimiteDeError > ProveedorTema > ProveedorUsuario > RouterProvider no es arbitrario. Cada capa envuelve a la siguiente por una razón concreta:

flowchart TD
    SM["StrictMode<br/><i>comprobaciones de desarrollo</i>"] --> LE["LimiteDeError<br/><i>captura fallos de TODO,<br/>incluido el enrutador</i>"]
    LE --> PT["ProveedorTema<br/><i>sobrevive a los cambios de ruta</i>"]
    PT --> PU["ProveedorUsuario<br/><i>la sesión NO se pierde al navegar</i>"]
    PU --> RP["RouterProvider<br/><i>decide qué pantalla pintar</i>"]
    RP --> PAG["La página activa<br/><i>cambia con la URL</i>"]
    style RP fill:#e0f2fe
    style PAG fill:#dcfce7

El argumento clave: lo que está por encima del enrutador no se desmonta al navegar. Si ProveedorUsuario estuviera dentro de una ruta, cambiar de / a /estaciones desmontaría el proveedor, su useState se reiniciaría y el usuario perdería la sesión en cada clic del menú. Lo mismo con el tema: pasarías de oscuro a claro cada vez que cambiases de pantalla.

En forma de tabla, el criterio para decidir dónde colocar cada proveedor:

Situación Dónde va
El estado debe sobrevivir a toda la vida de la aplicación (sesión, tema) Fuera del enrutador, en main.jsx
El estado es del marco visual y lo consumen varias pantallas (avisos, reservas) En la ruta raíz (Diseno), 06-03
El estado solo interesa a una pantalla (término de búsqueda del catálogo) Dentro de esa página

Y el LimiteDeError global por encima de todo tiene un motivo adicional: si el propio enrutador lanzara un error —una ruta mal configurada, un element que no es un elemento—, alguien tiene que capturarlo. En 06-03 verás que además existe errorElement, que actúa dentro del enrutador y por rama de ruta; los dos mecanismos conviven y se reparten el trabajo.

  1. Enlaces: <Link> frente a <a href>

Momento de saldar la deuda del módulo 2. Recordemos la Cabecera tal como lleva escrita desde entonces:

// src/componentes/Cabecera.jsx — la versión con la deuda
<nav>
  <a href="#catalogo">Catálogo</a>
  <a href="#estaciones">Estaciones</a>
  <a href="#reservas">Mis reservas</a>
</nav>

La tentación es sustituir #catalogo por /:

<a href="/">Catálogo</a>   {/* ❌ NO hagas esto en una SPA */}

Y es un error, aunque «funcione». Compara lo que ocurre con cada uno:

<a href="/estaciones"> <Link to="/estaciones">
¿Qué HTML produce? <a href="/estaciones"> <a href="/estaciones"> (¡el mismo!)
Al hacer clic El navegador descarta la aplicación y pide la página al servidor Intercepta el clic, pushState y repinta
Recarga completa No
Estado de React Se pierde todo: sesión, tema, reservas, avisos Intacto
Tiempo Cientos de milisegundos, con pantalla en blanco Instantáneo
Clic con Ctrl / botón central Abre en pestaña nueva ✅ Abre en pestaña nueva ✅
Buscadores y lectores de pantalla Lo ven como un enlace ✅ Lo ven como un enlace ✅

Ese detalle de las dos últimas filas es importante y suele sorprender: <Link> renderiza un <a> real con su href. No es un <div> con un onClick, error habitual de quien se fabrica sus propios enlaces. Por eso conserva todo lo bueno de un enlace HTML —abrir en pestaña nueva, copiar dirección, indexación, navegación por teclado, anuncio correcto en un lector de pantalla, como exigía 03-06— y solo cambia el comportamiento del clic simple, que intercepta con preventDefault().

La regla es sencilla:

  • Navegación dentro de la aplicación → <Link to="…">.
  • Enlace a otro sitio web, a un PDF o a mailto:<a href="…"> de toda la vida. React Router no debe interceptarlo.
// Correcto: interno con Link, externo con a
<Link to="/estaciones">Estaciones</Link>
<a href="https://www.ciclourbano.test/ayuda" target="_blank" rel="noreferrer">
  Centro de ayuda
</a>

Un aviso que ahorra horas de depuración: si en algún momento ves que al pulsar un enlace se pierde la sesión o parpadea toda la pantalla, casi seguro que has dejado un <a href> interno sin convertir. Es el fallo más repetido al enrutar una aplicación existente.

  1. <NavLink> y la clase activa: la nav de Cabecera

Para un menú de navegación hace falta algo más: marcar visualmente cuál es la pantalla actual. <NavLink> es un <Link> que sabe si su destino coincide con la URL activa.

// src/componentes/Cabecera.jsx — la deuda del módulo 2, saldada
import { NavLink } from 'react-router';
import MenuUsuario from './MenuUsuario.jsx';
import estilos from './Cabecera.module.css';

function Cabecera() {
  // Función que decide la clase según el estado del enlace
  const clasesEnlace = ({ isActive }) =>
    isActive ? `${estilos.enlace} ${estilos.activo}` : estilos.enlace;

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

      <nav aria-label="Navegación principal">
        <NavLink to="/" end className={clasesEnlace}>
          Catálogo
        </NavLink>
        <NavLink to="/estaciones" className={clasesEnlace}>
          Estaciones
        </NavLink>
        <NavLink to="/reservas" className={clasesEnlace}>
          Mis reservas
        </NavLink>
      </nav>

      <MenuUsuario />
    </header>
  );
}

export default Cabecera;

Tres cosas que explicar:

className puede ser una función. NavLink la llama con un objeto { isActive, isPending, isTransitioning } y usa lo que devuelva. Es la única prop de todo el proyecto que se comporta así, y merece la pena recordar por qué: el componente no sabe qué nombres de clase usa tu CSS Module, así que te deja decidir a ti. Lo mismo vale para style y para children:

{/* children como función: útil para añadir un icono solo al activo */}
<NavLink to="/reservas">
  {({ isActive }) => (
    <>
      {isActive && <span aria-hidden="true">▸ </span>}
      Mis reservas
    </>
  )}
</NavLink>

La prop end no es opcional en el enlace al catálogo. Por defecto, un NavLink se considera activo si la URL empieza por su destino. Como todas las rutas empiezan por /, el enlace «Catálogo» aparecería marcado siempre, incluso estando en /estaciones. end exige coincidencia exacta y resuelve el problema:

URL actual <NavLink to="/"> <NavLink to="/" end> <NavLink to="/estaciones">
/ activo activo inactivo
/estaciones activo ❌ inactivo activo
/estaciones/est-02 activo ❌ inactivo activo (deseable)

Fíjate en la última fila: para el enlace «Estaciones» sí queremos el comportamiento por defecto, porque estando en el detalle de una estación tiene sentido que la sección siga marcada. Por eso end va solo en el primero.

Accesibilidad. Marcar el enlace activo solo con color no basta (03-06). NavLink añade automáticamente aria-current="page" al elemento activo, lo que un lector de pantalla anuncia como «página actual». Aprovecha ese atributo también en el CSS, para no depender de una clase:

/* src/componentes/Cabecera.module.css */
.enlace {
  color: var(--color-texto);
  text-decoration: none;
  padding: calc(var(--espacio) / 2) var(--espacio);
  border-radius: var(--radio);
}

.enlace:hover {
  background: var(--color-borde);
}

.activo,
.enlace[aria-current='page'] {
  color: var(--color-marca);
  font-weight: 700;
  /* Además del color, un indicador no cromático */
  box-shadow: inset 0 -2px 0 var(--color-marca);
}

  1. Segmentos dinámicos y useParams

La ruta /bicicletas/:bicicletaId tiene un segmento dinámico: los dos puntos indican que ese trozo es variable y su valor debe capturarse.

URL ¿Coincide con /bicicletas/:bicicletaId? useParams() devuelve
/bicicletas/bici-003 { bicicletaId: 'bici-003' }
/bicicletas/42 { bicicletaId: '42' }
/bicicletas No (falta el segmento)
/bicicletas/bici-003/piezas No (sobra un segmento)

Así se lee en la página:

// src/paginas/PaginaFichaBicicleta.jsx
import { useParams, Link } from 'react-router';
import { bicicletas, estaciones } from '../datos/dominio.js';
import EtiquetaEstado from '../componentes/EtiquetaEstado.jsx';
import PaginaNoEncontrada from './PaginaNoEncontrada.jsx';

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

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

  // El parámetro puede ser cualquier cosa: la URL la escribe el usuario
  if (!bicicleta) {
    return <PaginaNoEncontrada recurso="bicicleta" identificador={bicicletaId} />;
  }

  const estacion = estaciones.find((est) => est.id === bicicleta.estacionId);

  return (
    <article>
      <h2>{bicicleta.modelo}</h2>
      <EtiquetaEstado estado={bicicleta.estado} />
      <dl>
        <dt>Tipo</dt>
        <dd>{bicicleta.tipo}</dd>
        <dt>Estación</dt>
        <dd>
          <Link to={`/estaciones/${bicicleta.estacionId}`}>
            {estacion ? estacion.nombre : 'Sin asignar'}
          </Link>
        </dd>
        <dt>Precio por hora</dt>
        <dd>{bicicleta.precioHora.toFixed(2)} €</dd>
      </dl>
      <Link to="/">Volver al catálogo</Link>
    </article>
  );
}

export default PaginaFichaBicicleta;

Y el enlace que lleva hasta aquí, desde TarjetaBicicleta:

// src/componentes/TarjetaBicicleta.jsx — fragmento
<h3>
  <Link to={`/bicicletas/${bicicleta.id}`}>{bicicleta.modelo}</Link>
</h3>

La conversión de tipos es responsabilidad tuya. Este es el punto que más fallos silenciosos provoca:

Todos los parámetros de ruta son cadenas de texto. Siempre. La URL es texto; React Router no puede saber si 42 es un número, un código postal o un identificador.

En CicloUrbano no molesta, porque los identificadores ya son cadenas (bici-003). Pero si tu API usara identificadores numéricos, esto fallaría de forma desconcertante:

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

bicicletas.find((b) => b.id === bicicletaId); // ❌ 3 === '3' es false → undefined
bicicletas.find((b) => b.id == bicicletaId);  // ⚠️ funciona, pero == es mala idea
bicicletas.find((b) => b.id === Number(bicicletaId)); // ✅ conversión explícita

Conviértelo cuanto antes y en un solo sitio, y valida el resultado:

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

if (!Number.isInteger(idNumerico) || idNumerico <= 0) {
  return <PaginaNoEncontrada recurso="bicicleta" identificador={bicicletaId} />;
}

  1. Qué hacer cuando el identificador no existe

Cualquiera puede escribir /bicicletas/bici-999 en la barra de direcciones, o llegar desde un enlace antiguo a una bicicleta dada de baja. Tienes tres estrategias, y elegir bien importa:

Estrategia Cómo Cuándo conviene
Pintar el «no encontrado» en su sitio if (!bicicleta) return <PaginaNoEncontrada … /> Recomendada. La URL se conserva, el usuario puede corregirla y el enlace sigue siendo compartible para depurar
Redirigir a la lista <Navigate to="/" replace /> Cuando el detalle no tiene sentido sin contexto y la lista es una alternativa útil. Se ve en 06-04
Lanzar y dejar que lo capture el enrutador throw new Response('No encontrada', { status: 404 }) + errorElement Aplicaciones grandes con manejo de errores centralizado. Se ve en 06-03

Amplía PaginaNoEncontrada para que sirva a los dos usos —URL inexistente y recurso inexistente— con un mensaje afinado:

// src/paginas/PaginaNoEncontrada.jsx
import { Link, useLocation } from 'react-router';
import Aviso from '../componentes/Aviso.jsx';

/**
 * Pantalla de «no encontrado» de CicloUrbano.
 * Props:
 *  - recurso      (cadena, opcional): 'bicicleta', 'estación'… Si falta, es una URL desconocida
 *  - identificador (cadena, opcional): el id que no se ha encontrado
 */
function PaginaNoEncontrada({ recurso, identificador }) {
  const { pathname } = useLocation();

  const titulo = recurso
    ? `No existe ninguna ${recurso} con el identificador «${identificador}»`
    : 'Esta página no existe';

  return (
    <section>
      <Aviso tono="error" titulo={titulo}>
        <p>
          {recurso
            ? 'Puede que se haya dado de baja o que el enlace esté anticuado.'
            : `La dirección ${pathname} no corresponde a ninguna pantalla de CicloUrbano.`}
        </p>
      </Aviso>
      <p>
        <Link to="/">Ir al catálogo</Link> · <Link to="/estaciones">Ver estaciones</Link>
      </p>
    </section>
  );
}

export default PaginaNoEncontrada;

Una precisión honesta que conviene conocer: esta pantalla no devuelve un código HTTP 404. El servidor ha entregado el index.html con estado 200 y el 404 es solo visual. Para que un buscador reciba el código correcto hace falta renderizado en servidor (10-01). En una aplicación interna como el panel de CicloUrbano no es un problema; en una web pública, sí.

  1. Parámetros de consulta con useSearchParams

Vuelve a PaginaCatalogo. El filtro por tipo vive hoy en un useState, y eso tiene el mismo defecto que denunciábamos en 06-01: si un operario filtra por electrica y le pasa la dirección a un compañero, este ve el catálogo sin filtrar. El filtro es información sobre lo que el usuario está mirando: pertenece a la URL.

useSearchParams es a los parámetros de consulta lo que useState es al estado local, con una firma deliberadamente parecida:

const [parametros, establecerParametros] = useSearchParams();

Con la diferencia de que parametros es un objeto URLSearchParams estándar del navegador, no un objeto plano. Sus métodos:

Método Qué hace
parametros.get('tipo') Devuelve el valor, o null si no está
parametros.getAll('tipo') Todos los valores, si la clave se repite
parametros.has('tipo') true / false
parametros.set('tipo', 'carga') Fija el valor (sobre una copia, ver abajo)
parametros.delete('tipo') Elimina la clave
parametros.toString() "tipo=carga&orden=precio"

Así queda el catálogo:

// src/paginas/PaginaCatalogo.jsx — con el filtro en la URL
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import BuscadorBicicletas from '../componentes/BuscadorBicicletas.jsx';
import SelectorTipo from '../componentes/SelectorTipo.jsx';
import ListaBicicletas from '../componentes/ListaBicicletas.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';

function PaginaCatalogo() {
  const [termino, setTermino] = useState('');
  const [parametros, establecerParametros] = useSearchParams();

  // La URL manda: si no hay ?tipo=, el filtro es 'todos'
  const tipoElegido = parametros.get('tipo') ?? 'todos';

  function manejarCambioDeTipo(nuevoTipo) {
    // Se parte SIEMPRE de los parámetros actuales para no perder los demás
    const siguientes = new URLSearchParams(parametros);

    if (nuevoTipo === 'todos') {
      siguientes.delete('tipo');   // sin filtro, URL limpia: '/' y no '/?tipo=todos'
    } else {
      siguientes.set('tipo', nuevoTipo);
    }

    // replace: cambiar de filtro no debería llenar el historial de entradas
    establecerParametros(siguientes, { replace: true });
  }

  const visibles = bicicletas
    .filter((bici) => tipoElegido === 'todos' || bici.tipo === tipoElegido)
    .filter((bici) => bici.modelo.toLowerCase().includes(termino.toLowerCase()));

  return (
    <section>
      <h2>Catálogo de bicicletas</h2>
      <BuscadorBicicletas termino={termino} alCambiarTermino={setTermino} />
      <SelectorTipo tipoElegido={tipoElegido} alCambiarTipo={manejarCambioDeTipo} />
      {visibles.length === 0 ? (
        <AvisoSinResultados termino={termino} tipo={tipoElegido} />
      ) : (
        <ListaBicicletas bicicletas={visibles} estaciones={estaciones} />
      )}
    </section>
  );
}

export default PaginaCatalogo;

Lo esencial de este código:

  • SelectorTipo no se ha tocado. Sigue siendo el componente controlado de 03-04, con sus props tipoElegido y alCambiarTipo. Lo único que ha cambiado es de dónde sale el valor y a dónde va el cambio. Eso es exactamente lo que buscábamos al mantener los componentes desacoplados del enrutador (apartado 3).
  • Se copia antes de modificar: new URLSearchParams(parametros). Mutar el objeto que devuelve el hook no repinta nada y puede provocar incoherencias, por la misma razón de inmutabilidad de 05-01.
  • ?tipo=todos se elimina en vez de escribirse. Un valor por defecto no debe ensuciar la URL; / y /?tipo=todos mostrarían lo mismo y son dos direcciones distintas, malo para compartir y para la analítica.
  • { replace: true } evita que cada clic en el filtro añada una entrada al historial. Si un usuario prueba cinco tipos, no querrá pulsar «atrás» cinco veces para salir del catálogo. Es un juicio de diseño: para una paginación sí suele preferirse dejar rastro.
  • El término de búsqueda sigue en useState. Decisión deliberada: se teclea letra a letra y llevarlo a la URL generaría decenas de entradas o exigiría combinarlo con el useDebounce de 05-06. Cuando el buscador tenga que ser compartible, el patrón será useSearchParams + useDebounce.

Qué se gana exactamente al convertir estado de React en estado de la URL:

Antes (useState) Después (useSearchParams)
/ siempre, filtre lo que filtre /?tipo=electrica
Compartir el enlace pierde el filtro El compañero ve exactamente lo mismo
F5 devuelve al catálogo sin filtrar F5 conserva el filtro
«Atrás» sale de la aplicación «Atrás» vuelve al filtro anterior (si no usas replace)
La analítica no distingue filtros Se puede medir qué tipo se consulta más
El estado inicial hay que inventarlo Lo dicta la URL

Y la contrapartida honesta: la URL es pública y compartible, así que ahí solo debe ir lo que no importe que se vea y se guarde. Filtros, ordenaciones, número de página, pestaña activa: sí. Datos personales, contenido de un formulario o cualquier secreto: nunca.

  1. La ruta comodín y la página de no encontrado

La última entrada del mapa es:

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

El asterisco es la ruta comodín (splat route): coincide con cualquier URL que ninguna otra ruta haya reclamado. Su papel es que un error tipográfico produzca una pantalla útil en vez de un vacío. Sin ella, /estacionez renderizaría nada en absoluto: pantalla en blanco, sin ningún error en consola. Es un fallo desconcertante y muy común olvidar esa línea.

El valor capturado por el comodín se lee con la clave '*', útil si quieres mostrarlo o registrarlo:

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

Un comodín también sirve en un punto intermedio, no solo en la raíz. { path: '/documentacion/*' } captura cualquier profundidad bajo ese prefijo, patrón habitual al integrar contenido externo. En CicloUrbano no hace falta.

  1. Cómo elige React Router la mejor coincidencia

Pregunta natural: si la URL es /reservas/nueva, ¿por qué no coincide con /reservas o con *? La respuesta es una de las mejores decisiones de diseño de React Router desde la v6:

React Router puntúa todas las rutas y elige la más específica, sin importar en qué orden las hayas escrito en el array.

El criterio de puntuación, simplificado:

Tipo de segmento Puntuación Ejemplo
Segmento literal Alta estaciones
Segmento dinámico Media :estacionId
Comodín * Mínima *

Aplicado a /estaciones/est-02, con el mapa completo:

Ruta candidata ¿Coincide? Puntuación Resultado
/estaciones/:estacionId literal + dinámico → alta Gana
/estaciones No, sobra un segmento Descartada
* mínima Perdedora

Compáralo con React Router v5 y anteriores, donde <Switch> tomaba la primera ruta que coincidiera en orden de escritura. Aquello obligaba a ordenar el array de más específico a más general, a usar exact por todas partes y producía errores muy difíciles de ver: una ruta colocada dos líneas más arriba de la cuenta se «comía» a todas las demás. Hoy puedes escribir el mapa en el orden que te resulte legible.

Dos matices que conviene retener:

  • La coincidencia debe ser completa. /estaciones no coincide con la URL /estaciones/est-02 a menos que tenga rutas hijas que absorban el resto (06-03) o termine en *.
  • end en NavLink es otra cosa. Gobierna solo la clase activa del enlace, no qué ruta se pinta. Es normal confundirlos al principio.
flowchart TD
    URL["URL: /estaciones/est-02"] --> CAND{"Rutas candidatas"}
    CAND --> R1["/estaciones/:estacionId<br/>literal + dinámico"]
    CAND --> R2["/estaciones<br/>❌ no cubre toda la URL"]
    CAND --> R3["*<br/>puntuación mínima"]
    R1 --> GANA["✅ Se renderiza<br/>PaginaDetalleEstacion<br/>params: { estacionId: 'est-02' }"]
    style GANA fill:#dcfce7
    style R2 fill:#fecaca

Errores Comunes y Consejos

Pasar el componente en vez del elemento. { path: '/', element: PaginaCatalogo } en lugar de element: <PaginaCatalogo />. No lanza un error claro: simplemente no se pinta nada. Recuerda que element recibe JSX ya creado.

Dejar <a href> internos sin convertir. El síntoma es inconfundible: al pulsar un enlace, la pantalla parpadea y se pierden la sesión, el tema y las reservas. Busca href="/ en tu proyecto y convierte todo lo interno a <Link>.

Olvidar end en el NavLink del inicio. El enlace «Catálogo» queda marcado como activo en todas las pantallas. Lo mismo pasa con cualquier NavLink cuyo destino sea prefijo de otras rutas.

Llamar a createBrowserRouter dentro de un componente. Cada render crearía un enrutador nuevo, y la aplicación volvería a la ruta inicial en cuanto algo repintara. El mapa se crea una vez, en el ámbito del módulo.

Mutar el objeto de useSearchParams. parametros.set('tipo', 'carga') sobre el objeto devuelto por el hook no provoca ningún repintado. Copia siempre: new URLSearchParams(parametros).

Poner un ? de más al construir la URL. establecerParametros('?tipo=carga') produce ??tipo=carga. Pasa un URLSearchParams o un objeto plano y deja que React Router ponga el separador.

Esperar un número en useParams. Todo es cadena. Convierte y valida, en un solo sitio y cuanto antes.

Olvidar la ruta *. Cualquier error tipográfico produce una pantalla en blanco silenciosa, sin error en consola. Es de los primeros fallos que un usuario reporta.

Consejo: nombra los parámetros igual en la ruta y en el código. path: '/bicicletas/:bicicletaId' y const { bicicletaId } = useParams(). Si la ruta dice :id y desestructuras bicicletaId, obtendrás undefined sin ningún aviso.

Consejo: mantén una única fuente de verdad por dato. Si el tipo elegido vive en la URL, no lo dupliques en un useState «para tenerlo a mano». Duplicarlo garantiza que antes o después se desincronicen, exactamente como advertía 05-01 sobre el estado derivado.

Consejo: comprueba la recarga en cada ruta nueva. Navegar con enlaces siempre funciona; recargar es lo que descubre los problemas de configuración del servidor. Con Vite en desarrollo no verás el fallo, pero conviene tener presente lo visto en 06-01 sobre la reescritura a index.html.

Ejercicios

Ejercicio 1: la ruta de detalle de estación

Añade a CicloUrbano la pantalla de detalle de estación, /estaciones/:estacionId. Debe:

  1. Leer el parámetro y buscar la estación en estaciones.
  2. Mostrar nombre, barrio y plazas.
  3. Listar las bicicletas cuyo estacionId coincida, cada una enlazando a su ficha.
  4. Mostrar el «no encontrado» si el identificador no existe.
  5. Incluir un enlace de vuelta a /estaciones.

Añade además el enlace desde TarjetaEstacion para llegar hasta ahí.

Ejercicio 2: ordenar el listado desde la URL

En PaginaEstaciones, añade un desplegable que permita ordenar las estaciones por nombre o por plazas, guardando la elección en un parámetro de consulta orden. Requisitos:

  • La URL debe quedar /estaciones?orden=plazas.
  • El valor por defecto (nombre) no debe aparecer en la URL.
  • No se deben perder otros parámetros de consulta que pudiera haber.
  • Un valor inválido escrito a mano (?orden=inventado) no debe romper la pantalla.

Ejercicio 3: detectar los fallos

Este mapa de rutas y esta cabecera tienen cinco errores. Encuéntralos y corrígelos.

// src/rutas.jsx
import { createBrowserRouter } from 'react-router';
import PaginaCatalogo from './paginas/PaginaCatalogo.jsx';
import PaginaFichaBicicleta from './paginas/PaginaFichaBicicleta.jsx';
import PaginaEstaciones from './paginas/PaginaEstaciones.jsx';

function crearRouter() {
  return createBrowserRouter([
    { path: '/', element: PaginaCatalogo },
    { path: '/bicicletas/:id', element: <PaginaFichaBicicleta /> },
    { path: '/estaciones', element: <PaginaEstaciones /> }
  ]);
}

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

function Cabecera() {
  return (
    <nav>
      <NavLink to="/" className={({ isActive }) => (isActive ? 'activo' : '')}>
        Catálogo
      </NavLink>
      <a href="/estaciones">Estaciones</a>
    </nav>
  );
}
// src/paginas/PaginaFichaBicicleta.jsx — fragmento
const { bicicletaId } = useParams();
const bicicleta = bicicletas.find((bici) => bici.id === bicicletaId);
return <h2>{bicicleta.modelo}</h2>;

Soluciones

Solución 1

// src/paginas/PaginaDetalleEstacion.jsx
import { useParams, Link } from 'react-router';
import { estaciones, bicicletas } from '../datos/dominio.js';
import EtiquetaEstado from '../componentes/EtiquetaEstado.jsx';
import PaginaNoEncontrada from './PaginaNoEncontrada.jsx';

function PaginaDetalleEstacion() {
  const { estacionId } = useParams();
  const estacion = estaciones.find((est) => est.id === estacionId);

  if (!estacion) {
    return <PaginaNoEncontrada recurso="estación" identificador={estacionId} />;
  }

  const flota = bicicletas.filter((bici) => bici.estacionId === estacion.id);

  return (
    <article>
      <h2>{estacion.nombre}</h2>
      <p>
        Barrio de {estacion.barrio} · {estacion.plazas} plazas ·{' '}
        {flota.length} bicicletas asignadas
      </p>

      <h3>Flota de la estación</h3>
      {flota.length === 0 ? (
        <p>No hay bicicletas asignadas a esta estación.</p>
      ) : (
        <ul>
          {flota.map((bici) => (
            <li key={bici.id}>
              <Link to={`/bicicletas/${bici.id}`}>{bici.modelo}</Link>{' '}
              <EtiquetaEstado estado={bici.estado} />
            </li>
          ))}
        </ul>
      )}

      <Link to="/estaciones">Volver a las estaciones</Link>
    </article>
  );
}

export default PaginaDetalleEstacion;
// src/componentes/TarjetaEstacion.jsx — fragmento con el enlace añadido
<h3>
  <Link to={`/estaciones/${estacion.id}`}>{estacion.nombre}</Link>
</h3>

Con la ruta ya presente en rutas.jsx, no hay nada más que registrar. Y observa que /estaciones y /estaciones/est-02 conviven sin problema gracias a la puntuación del apartado 13: el orden en el array es irrelevante.

Solución 2

// src/paginas/PaginaEstaciones.jsx
import { useSearchParams } from 'react-router';
import TarjetaEstacion from '../componentes/TarjetaEstacion.jsx';
import { estaciones, bicicletas } from '../datos/dominio.js';

const ORDENES_VALIDOS = ['nombre', 'plazas'];
const ORDEN_POR_DEFECTO = 'nombre';

function PaginaEstaciones() {
  const [parametros, establecerParametros] = useSearchParams();

  // Validación: un valor inventado en la URL cae al valor por defecto
  const ordenBruto = parametros.get('orden');
  const orden = ORDENES_VALIDOS.includes(ordenBruto) ? ordenBruto : ORDEN_POR_DEFECTO;

  function manejarCambioDeOrden(evento) {
    const nuevo = evento.target.value;
    const siguientes = new URLSearchParams(parametros); // copia: conserva lo demás

    if (nuevo === ORDEN_POR_DEFECTO) {
      siguientes.delete('orden');   // el valor por defecto no ensucia la URL
    } else {
      siguientes.set('orden', nuevo);
    }

    establecerParametros(siguientes, { replace: true });
  }

  // Copia antes de ordenar: sort() muta el array original (05-01)
  const ordenadas = [...estaciones].sort((a, b) =>
    orden === 'plazas' ? b.plazas - a.plazas : a.nombre.localeCompare(b.nombre)
  );

  return (
    <section>
      <h2>Estaciones</h2>

      <p>
        <label htmlFor="orden-estaciones">Ordenar por</label>{' '}
        <select id="orden-estaciones" value={orden} onChange={manejarCambioDeOrden}>
          <option value="nombre">Nombre (A-Z)</option>
          <option value="plazas">Plazas (mayor primero)</option>
        </select>
      </p>

      <ul>
        {ordenadas.map((estacion) => (
          <li key={estacion.id}>
            <TarjetaEstacion
              estacion={estacion}
              bicicletasEnEstacion={bicicletas.filter((b) => b.estacionId === estacion.id)}
            />
          </li>
        ))}
      </ul>
    </section>
  );
}

export default PaginaEstaciones;

Los cuatro requisitos quedan cubiertos por: la lista blanca ORDENES_VALIDOS (validación), el delete cuando el valor es el de por defecto (URL limpia), la copia new URLSearchParams(parametros) (no perder otros parámetros) y el [...estaciones] antes de sort (no mutar los datos originales). El <select> es un componente controlado de 03-04, con la particularidad de que su valor sale de la URL, no de un useState.

Solución 3

Los cinco errores:

  1. element: PaginaCatalogo pasa el componente, no el elemento. Debe ser element: <PaginaCatalogo />. Síntoma: pantalla en blanco sin error claro.
  2. createBrowserRouter dentro de una función que se exporta como crearRouter. Si main.jsx la llama en cada render, se crea un enrutador nuevo cada vez. Debe crearse una sola vez en el ámbito del módulo y exportarse con nombre: export const router = createBrowserRouter([...]);.
  3. Falta la ruta comodín { path: '*', element: <PaginaNoEncontrada /> }. Cualquier URL desconocida deja la pantalla vacía en silencio.
  4. Desajuste de nombres: la ruta declara :id pero la página desestructura bicicletaId, que será undefined. Deben coincidir; lo correcto según el mapa del módulo es :bicicletaId en ambos sitios.
  5. En Cabecera, <a href="/estaciones"> provoca una recarga completa y pierde todo el estado de React. Debe ser <NavLink to="/estaciones">.

Y un sexto fallo, latente, que conviene señalar: en PaginaFichaBicicleta no se comprueba si bicicleta existe, así que /bicicletas/bici-999 reventará con «Cannot read properties of undefined (reading 'modelo')». Añade la guarda del apartado 10. Nota, además, que ese error sí lo capturaría el LimiteDeError global de 04-05, pero tumbando la aplicación entera para algo que es simplemente una URL mal escrita: manejarlo en su sitio es mucho mejor experiencia.

Conclusión

CicloUrbano ya está enrutada. Has instalado react-router —el paquete de la v7, sabiendo que react-router-dom es lo que verás en proyectos de la v6—, has creado src/rutas.jsx con el mapa completo como array de objetos y has montado <RouterProvider router={router} /> en main.jsx conservando el orden StrictMode > LimiteDeError > ProveedorTema > ProveedorUsuario, porque lo que está por encima del enrutador no se desmonta al navegar y por eso la sesión y el tema sobreviven a los cambios de pantalla. El proyecto tiene una carpeta src/paginas/ con una pantalla por ruta y una regla clara: las páginas pueden depender del enrutador; los componentes reutilizables reciben sus datos por props y siguen siendo probables por separado.

Por el camino has saldado la deuda que arrastraba la Cabecera desde el módulo 2: los tres <a href="#…"> son ahora <NavLink> con clase activa, aria-current="page" gratis y la prop end en el enlace al catálogo para que no aparezca marcado en todas las pantallas. Sabes que <Link> produce un <a> real —con todo lo bueno de un enlace HTML— y solo intercepta el clic simple, mientras que un <a href> interno destruye la aplicación y se lleva por delante sesión, tema y reservas. Has leído el identificador de la ficha con useParams, recordando que todo parámetro de ruta es una cadena y que la conversión y la validación son tuyas, y has decidido qué hacer cuando el recurso no existe. Y has convertido el filtro del SelectorTipo de estado de React en estado de la URL con useSearchParams, ganando enlaces compartibles, recarga que conserva el filtro y analítica útil, sin tocar una sola línea del componente controlado. Cierran el cuadro la ruta comodín * —cuya ausencia produce pantallas en blanco silenciosas— y el sistema de puntuación que elige la coincidencia más específica sin depender del orden del array.

Queda un problema visible: las pantallas se pintan solas, sin cabecera ni pie, porque Diseno se quedó fuera del mapa. Reintroducirlo como un componente que envuelve a cada página funcionaría, pero volvería a montar la cabecera en cada navegación y no permitiría compartir datos entre marco y pantalla. La solución correcta es hacer de Diseno la ruta padre de todas las demás y abrir en él un hueco donde pintar la hija activa; con la misma idea construirás las pestañas flota e incidencias dentro del detalle de estación, las migas de pan y el manejo de errores por rama. La próxima lección es Rutas Anidadas.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados