La lección anterior dejó CicloUrbano enrutada pero mutilada: cada pantalla se pinta sola, sin Cabecera y sin PieDePagina, porque Diseno se quedó fuera del mapa de rutas. La solución obvia —envolver cada página en <Diseno>— funciona y es mala: repite el mismo marcado nueve veces, vuelve a montar la cabecera en cada navegación y no permite que el marco comparta datos con la pantalla. React Router propone algo mejor: las rutas se anidan igual que los componentes, de modo que una ruta padre pinta el marco y reserva un hueco donde aparecerá la ruta hija activa. En esta lección aprenderás ese mecanismo con children y <Outlet />, convertirás Diseno en la ruta raíz, distinguirás las rutas índice de las rutas con path: '', usarás rutas sin path para agrupar sin ensuciar la URL, construirás el detalle de estación con pestañas flota e incidencias, pasarás datos del padre al hijo con useOutletContext, generarás migas de pan a partir del propio mapa con handle y useMatches, y manejarás errores por rama con errorElement.

Contenido

  1. La idea: composición de interfaz a través de la URL
  2. children y <Outlet />
  3. Diseno como ruta raíz: qué mejora exactamente
  4. Rutas índice: index: true
  5. index: true frente a path: ''
  6. Rutas sin path: agrupar sin añadir segmento
  7. Rutas relativas y absolutas
  8. Caso completo: detalle de estación con pestañas
  9. useOutletContext: datos del padre a la hija
  10. Migas de pan con handle y useMatches
  11. Errores por rama: errorElement y useRouteError
  12. errorElement frente a LimiteDeError
  13. Nota sobre loader y action

  1. La idea: composición de interfaz a través de la URL

Vuelve a mirar una URL de CicloUrbano y léela por trozos:

/estaciones/est-02/incidencias
 └────┬───┘ └──┬──┘ └────┬────┘
      │        │         └─ pestaña dentro del detalle
      │        └─ qué estación
      └─ sección de la aplicación

Cada segmento acota al anterior. Y si te fijas, la interfaz que corresponde a esa URL tiene exactamente la misma estructura de muñecas rusas: el marco de la aplicación contiene la pantalla de estaciones, que contiene el detalle de est-02, que contiene la pestaña de incidencias.

Anidar rutas consiste en declarar que una ruta es hija de otra, de modo que cuando la hija esté activa el padre también se renderiza, y la hija aparece dentro de él.

Esa correspondencia entre la jerarquía de la URL y la jerarquía de componentes es lo que hace que el enrutamiento en React encaje tan bien con lo que ya sabes de composición (04-02): un mapa de rutas anidado es un árbol de componentes, escrito de otra manera.

flowchart TD
    subgraph URL["URL: /estaciones/est-02/incidencias"]
        S1["/estaciones"] --> S2["/est-02"] --> S3["/incidencias"]
    end
    subgraph UI["Interfaz renderizada"]
        C0["Diseno<br/>Cabecera + Outlet + PieDePagina"]
        C0 --> C1["PaginaDetalleEstacion<br/>título + pestañas + Outlet"]
        C1 --> C2["PestanaIncidencias"]
    end
    S1 -.-> C0
    S2 -.-> C1
    S3 -.-> C2
    style C0 fill:#e0f2fe
    style C1 fill:#fef9c3
    style C2 fill:#dcfce7

  1. children y <Outlet />

Dos piezas y ya está. En el mapa, la propiedad children; en el componente padre, el componente <Outlet />.

// src/rutas.jsx — la forma más simple de anidamiento
import { createBrowserRouter } from 'react-router';
import Diseno from './componentes/Diseno.jsx';
import PaginaCatalogo from './paginas/PaginaCatalogo.jsx';
import PaginaEstaciones from './paginas/PaginaEstaciones.jsx';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Diseno />,          // ← se pinta SIEMPRE
    children: [
      { index: true, element: <PaginaCatalogo /> },
      { path: 'estaciones', element: <PaginaEstaciones /> }
    ]
  }
]);
// src/componentes/Diseno.jsx — de children a Outlet
import { Outlet } from 'react-router';
import Cabecera from './Cabecera.jsx';
import PieDePagina from './PieDePagina.jsx';
import estilos from './Diseno.module.css';

function Diseno() {
  return (
    <div className={estilos.diseno}>
      <Cabecera />
      <main className={estilos.principal}>
        <Outlet />          {/* ← aquí se pinta la ruta hija activa */}
      </main>
      <PieDePagina />
    </div>
  );
}

export default Diseno;

Compáralo con la versión de 04-02, que recibía { children }:

Diseno con children (04-02) Diseno con <Outlet /> (ahora)
Quién decide el contenido Quien escribe <Diseno>…</Diseno> El enrutador, según la URL
Firma function Diseno({ children }) function Diseno()
Dónde se usa Manualmente en App Como element de una ruta
Cuándo cambia el contenido Cuando el padre repinta Al navegar

<Outlet /> es el children del enrutador. Es la misma idea de hueco de 04-02, pero rellenado automáticamente por la ruta hija que coincide con la URL. Nota que Diseno ya no acepta children: si necesitas ambas cosas —y a veces ocurre—, puedes tener las dos, pero en CicloUrbano no hace falta.

Y las reglas que gobiernan el anidamiento:

  • Los path de las hijas son relativos al padre y no llevan / inicial. El hijo path: 'estaciones' dentro del padre path: '/' produce la URL /estaciones. Escribir path: '/estaciones' en la hija también funciona (es absoluta), pero rompe el patrón y complica reorganizar el árbol.
  • Si el padre no tiene <Outlet />, la hija no se ve. Es el error más frecuente de esta lección: todo está bien configurado, la URL coincide, pero la pantalla no aparece porque falta el hueco.
  • Se puede anidar sin límite de profundidad, aunque más de tres o cuatro niveles suele indicar que el modelo de URLs se ha complicado de más.

  1. Diseno como ruta raíz: qué mejora exactamente

Este es el mapa completo de CicloUrbano ya anidado, sustituyendo al plano de 06-02:

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

import Diseno from './componentes/Diseno.jsx';
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 PestanaFlota from './paginas/PestanaFlota.jsx';
import PestanaIncidencias from './paginas/PestanaIncidencias.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';
import PaginaErrorRuta from './paginas/PaginaErrorRuta.jsx';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Diseno />,
    errorElement: <PaginaErrorRuta />,
    handle: { miga: 'Inicio' },
    children: [
      { index: true, element: <PaginaCatalogo />, handle: { miga: 'Catálogo' } },

      {
        path: 'bicicletas/:bicicletaId',
        element: <PaginaFichaBicicleta />,
        handle: { miga: 'Ficha de bicicleta' }
      },

      {
        path: 'estaciones',
        handle: { miga: 'Estaciones' },
        children: [
          { index: true, element: <PaginaEstaciones /> },
          {
            path: ':estacionId',
            element: <PaginaDetalleEstacion />,
            handle: { miga: 'Detalle de estación' },
            children: [
              { index: true, element: <PestanaFlota /> },
              { path: 'incidencias', element: <PestanaIncidencias /> }
            ]
          }
        ]
      },

      {
        path: 'reservas',
        handle: { miga: 'Mis reservas' },
        children: [
          { index: true, element: <PaginaReservas /> },
          { path: 'nueva', element: <PaginaNuevaReserva />, handle: { miga: 'Nueva reserva' } }
        ]
      },

      { path: 'acceso', element: <PaginaAcceso />, handle: { miga: 'Acceso' } },
      { path: 'taller', element: <PaginaTaller />, handle: { miga: 'Taller' } },

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

Fíjate en que la ruta estaciones no tiene element: solo agrupa. Cuando una ruta tiene hijas y ningún elemento, React Router pinta implícitamente un <Outlet /> en su lugar, así que las hijas suben directamente al hueco del abuelo. Es una forma limpia de organizar el mapa por secciones.

Qué mejora exactamente convertir Diseno en ruta raíz en lugar de envolver cada página:

<Diseno> alrededor de cada página Diseno como ruta raíz
Repetición del marcado Nueve veces Una
Al navegar entre pantallas Diseno, Cabecera y PieDePagina se desmontan y se vuelven a montar Persisten: React solo cambia lo que hay en el <Outlet />
Estado dentro de la cabecera (menú abierto, buscador) Se pierde en cada navegación Se conserva
Efectos de la cabecera Se ejecutan su limpieza y su montaje en cada clic Se ejecutan una vez
Posición del desplazamiento de una barra lateral larga Vuelve arriba Se mantiene
Proveedores colocados ahí (ProveedorAvisos, ProveedorReservas) Se reinician: se pierden los avisos y las reservas Sobreviven a la navegación
Animaciones de entrada de la cabecera Se repiten molestamente No se repiten

La fila de los proveedores es la de mayor calado y explica una decisión pendiente de 06-02. ProveedorReservas y ProveedorAvisos van dentro de Diseno, no en main.jsx:

// src/componentes/Diseno.jsx — versión definitiva
import { Outlet } from 'react-router';
import { ProveedorReservas } from '../contextos/ContextoReservas.jsx';
import { ProveedorAvisos } from '../contextos/ContextoAvisos.jsx';
import Cabecera from './Cabecera.jsx';
import PieDePagina from './PieDePagina.jsx';
import ListaAvisos from './ListaAvisos.jsx';
import estilos from './Diseno.module.css';

function Diseno() {
  return (
    <ProveedorReservas>
      <ProveedorAvisos>
        <div className={estilos.diseno}>
          <Cabecera />
          <ListaAvisos />           {/* los avisos se ven desde cualquier pantalla */}
          <main className={estilos.principal}>
            <Outlet />
          </main>
          <PieDePagina />
        </div>
      </ProveedorAvisos>
    </ProveedorReservas>
  );
}

export default Diseno;

El razonamiento es el de 06-02, un nivel más abajo: ProveedorTema y ProveedorUsuario van fuera del enrutador porque deben sobrevivir a absolutamente todo; ProveedorReservas y ProveedorAvisos van en la ruta raíz porque pertenecen al marco de la aplicación, lo consumen varias pantallas y así quedan junto a la interfaz que los usa. Como Diseno no se desmonta al navegar, el efecto práctico es el mismo y el mapa se lee mejor. Este detalle es el que permite que en 06-04 crees una reserva en /reservas/nueva y la veas en /reservas: si el proveedor se reiniciara al cambiar de pantalla, la reserva se habría evaporado por el camino.

  1. Rutas índice: index: true

Pregunta: con el padre path: 'estaciones' y la hija path: ':estacionId', ¿qué se ve en /estaciones a secas? Nada dentro del <Outlet />: ninguna hija coincide.

Para eso está la ruta índice:

Una ruta índice (index: true) es la hija que se renderiza en el <Outlet /> del padre cuando la URL coincide exactamente con el padre y no hay más segmentos.

{
  path: 'estaciones',
  children: [
    { index: true, element: <PaginaEstaciones /> },      // → /estaciones
    { path: ':estacionId', element: <PaginaDetalleEstacion /> }  // → /estaciones/est-02
  ]
}
URL Ruta hija activa Qué se ve en el <Outlet />
/estaciones la índice PaginaEstaciones
/estaciones/est-02 :estacionId PaginaDetalleEstacion
/estaciones/est-99 :estacionId PaginaDetalleEstacion → «no encontrada»

Sus reglas:

  • Una ruta índice no tiene path. { index: true, path: 'algo' } es un error.
  • Una ruta índice no puede tener children. Es una hoja del árbol por definición.
  • Solo puede haber una por padre.
  • Es la pantalla por defecto de esa sección, y por eso da nombre al patrón: es el «índice» de la carpeta, como el index.html de un directorio en un servidor web.

En CicloUrbano hay tres: el catálogo bajo la raíz, el listado bajo estaciones y la pestaña de flota bajo el detalle de estación.

  1. index: true frente a path: ''

Existe una alternativa que parece equivalente y no lo es del todo:

{ index: true, element: <PaginaEstaciones /> }
{ path: '', element: <PaginaEstaciones /> }   // ¿lo mismo?

Ambas coinciden con la URL del padre. Las diferencias:

index: true path: ''
Coincide con la URL del padre
Puede tener children No
Intención declarada «Esta es la pantalla por defecto» «Esta ruta añade un marco sin cambiar la URL»
Comportamiento en enlaces relativos Se comporta como el padre Cuenta como un nivel más
Recomendación Úsala para pantallas por defecto Solo si necesitas un nivel de anidamiento extra sin segmento

En la práctica: usa index: true casi siempre. path: '' es la herramienta para un caso concreto —insertar un componente intermedio que además tenga hijas propias— y ahí compite con la ruta sin path del apartado siguiente, que suele expresarlo mejor.

Un aviso: path: '/' en una ruta hija no es lo mismo que path: ''. La barra la convierte en absoluta y produce coincidencias sorprendentes. Dentro de un padre, deja las hijas siempre relativas.

  1. Rutas sin path: agrupar sin añadir segmento

Este es un patrón que resuelve un problema muy concreto: aplicar un marco común a varias pantallas sin que la URL lo refleje.

Una ruta sin path (pathless route) es una ruta con element y children pero sin path. No consume ningún segmento de la URL: solo inserta su elemento en el árbol y deja que las hijas sigan como si no estuviera.

{
  path: '/',
  element: <Diseno />,
  children: [
    { index: true, element: <PaginaCatalogo /> },

    // Ruta SIN path: envuelve, pero no aparece en la URL
    {
      element: <RutaProtegida />,
      children: [
        { path: 'taller', element: <PaginaTaller /> },        // → /taller
        { path: 'informes', element: <PaginaInformes /> }      // → /informes
      ]
    }
  ]
}

Fíjate en el resultado: /taller sigue siendo /taller, no /protegido/taller. El componente RutaProtegida se renderiza entre Diseno y PaginaTaller, comprueba la sesión y decide si pinta su <Outlet /> o redirige. Este es el patrón 2 de 06-05, y lo verás allí desarrollado.

Otros usos habituales:

Uso Qué agrupa
Protección Varias pantallas tras un mismo guardián de sesión
Manejo de errores Varias pantallas bajo un mismo errorElement
Marco secundario Una barra lateral compartida por tres pantallas cuyas URLs no comparten prefijo
Proveedor acotado Un contexto que solo debe existir en un grupo de pantallas

La regla mental: path es para la URL; el anidamiento es para la interfaz. Que dos pantallas compartan marco no obliga a que compartan prefijo, y una ruta sin path es justamente la forma de desacoplar ambas cosas.

  1. Rutas relativas y absolutas

En path y en to, una cadena que empieza por / es absoluta y una que no, relativa. La diferencia importa más de lo que parece.

En path (dentro del mapa):

{
  path: 'estaciones',
  children: [
    { path: ':estacionId', … },    // ✅ relativa → /estaciones/est-02
    { path: '/estaciones/:estacionId', … }  // ⚠️ absoluta: funciona, pero frágil
  ]
}

La relativa se recomienda porque si mañana renombras la sección a paradas, cambias una línea y todo el subárbol sigue. Con rutas absolutas tendrías que editar cada hija.

En to (dentro de los componentes): la referencia es la ruta activa, no la URL literal. Estando en /estaciones/est-02:

to Destino Comentario
"/" / Absoluta: desde la raíz
"/estaciones" /estaciones Absoluta
"incidencias" /estaciones/est-02/incidencias Relativa: se añade al final
".." /estaciones Sube un nivel de ruta
"../est-01" /estaciones/est-01 Sube y baja

El caso de .. tiene una sutileza que conviene conocer: por defecto sube un nivel de la jerarquía de rutas, no de segmentos de URL. Si quieres el comportamiento de segmentos, como en un sistema de ficheros, se pide explícitamente:

<Link to=".." relative="path">Subir un segmento de URL</Link>

Consejo práctico: usa rutas relativas dentro de un subárbol (las pestañas de una pantalla, la navegación interna de una sección) y absolutas para la navegación principal (el menú de Cabecera). Las relativas hacen que un subárbol se pueda mover de sitio sin tocar sus enlaces; las absolutas dejan claro el destino en el menú global.

  1. Caso completo: detalle de estación con pestañas

Vamos con el caso central de la lección. /estaciones/:estacionId muestra los datos de la estación y dos pestañas: flota (por defecto) e incidencias. Cada pestaña es una ruta, así que tiene su propia URL, se puede compartir y el botón atrás alterna entre ellas.

// src/paginas/PaginaDetalleEstacion.jsx
import { useParams, NavLink, Outlet, Link } from 'react-router';
import { estaciones, bicicletas } from '../datos/dominio.js';
import PaginaNoEncontrada from './PaginaNoEncontrada.jsx';
import estilos from './PaginaDetalleEstacion.module.css';

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

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

  // Se calcula UNA vez aquí y se comparte con las pestañas
  const flota = bicicletas.filter((bici) => bici.estacionId === estacion.id);

  const clasesPestana = ({ isActive }) =>
    isActive ? `${estilos.pestana} ${estilos.activa}` : estilos.pestana;

  return (
    <article>
      <p className={estilos.migaSimple}>
        <Link to="/estaciones">← Estaciones</Link>
      </p>

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

      {/* Las pestañas son NavLink relativos: no repiten el id de la estación */}
      <nav className={estilos.pestanas} aria-label="Secciones de la estación">
        <NavLink to="." end className={clasesPestana}>
          Flota
        </NavLink>
        <NavLink to="incidencias" className={clasesPestana}>
          Incidencias
        </NavLink>
      </nav>

      {/* La pestaña activa se pinta aquí, y recibe la flota ya calculada */}
      <Outlet context={{ estacion, flota }} />
    </article>
  );
}

export default PaginaDetalleEstacion;

Detalles que hacen que esto funcione:

  • to="." apunta a la propia ruta activa, es decir, a /estaciones/est-02. Con end exige coincidencia exacta, de modo que estando en /incidencias la pestaña «Flota» no aparece marcada. Sin end, ambas se verían activas a la vez, que es el error clásico de las pestañas anidadas.
  • to="incidencias" es relativo: se añade al final de la ruta actual. Nunca escribas aquí /estaciones/est-02/incidencias, porque tendrías que interpolar el identificador a mano y el componente dejaría de ser reutilizable.
  • La flota se calcula una vez en el padre y baja a las hijas por context, en lugar de que cada pestaña vuelva a filtrar el array. Es el apartado siguiente.
  • La navegación entre pestañas no vuelve a montar la cabecera de la estación: el <article>, el título y las pestañas persisten; solo cambia lo que hay dentro del <Outlet />. Exactamente la misma ganancia que con Diseno, un nivel más abajo.
flowchart TD
    D["Diseno<br/><i>Cabecera · Outlet · PieDePagina</i>"]
    D --> P["PaginaDetalleEstacion<br/><i>título · pestañas · Outlet</i>"]
    P --> F["PestanaFlota<br/><b>ruta índice</b><br/>/estaciones/est-02"]
    P --> I["PestanaIncidencias<br/>/estaciones/est-02/incidencias"]
    style D fill:#e0f2fe
    style P fill:#fef9c3
    style F fill:#dcfce7
    style I fill:#dcfce7

  1. useOutletContext: datos del padre a la hija

Problema real: PestanaFlota y PestanaIncidencias necesitan la estación y su flota. Podrían leer useParams() y volver a buscar en dominio.js, pero eso duplicaría el trabajo, duplicaría también la comprobación de «no encontrada» y —cuando los datos vengan de una API en 07-06— duplicaría la petición.

<Outlet /> acepta una prop context con lo que quieras pasar, y la hija lo lee con useOutletContext():

// src/paginas/PestanaFlota.jsx
import { useOutletContext, Link } from 'react-router';
import EtiquetaEstado from '../componentes/EtiquetaEstado.jsx';

function PestanaFlota() {
  const { estacion, flota } = useOutletContext();

  if (flota.length === 0) {
    return <p>No hay bicicletas asignadas a {estacion.nombre} ahora mismo.</p>;
  }

  return (
    <ul>
      {flota.map((bici) => (
        <li key={bici.id}>
          <Link to={`/bicicletas/${bici.id}`}>{bici.modelo}</Link>{' '}
          <EtiquetaEstado estado={bici.estado} />{' '}
          <span>{bici.precioHora.toFixed(2)} €/h</span>
        </li>
      ))}
    </ul>
  );
}

export default PestanaFlota;
// src/paginas/PestanaIncidencias.jsx
import { useOutletContext } from 'react-router';
import Aviso from '../componentes/Aviso.jsx';

function PestanaIncidencias() {
  const { estacion, flota } = useOutletContext();

  const enMantenimiento = flota.filter((bici) => bici.estado === 'mantenimiento');

  if (enMantenimiento.length === 0) {
    return <p>{estacion.nombre} no tiene incidencias abiertas.</p>;
  }

  return (
    <ul>
      {enMantenimiento.map((bici) => (
        <li key={bici.id}>
          <Aviso tono="advertencia" titulo={`${bici.modelo} en mantenimiento`}>
            <p>La bicicleta {bici.id} está retirada del servicio en {estacion.nombre}.</p>
          </Aviso>
        </li>
      ))}
    </ul>
  );
}

export default PestanaIncidencias;

useOutletContext frente a useContext —porque la duda es legítima después de 05-04:

useOutletContext Contexto de React (useContext)
Alcance Solo la ruta hija directa del <Outlet /> Todo el subárbol del proveedor
Cómo se declara <Outlet context={…} /> <ProveedorX> con createContext
Cuándo usarlo Datos de esta pantalla para sus pestañas Datos ambientales para media aplicación
Ejemplo en CicloUrbano { estacion, flota } usuario, tema, avisos, reservas
Verboso Nada: una prop Fichero de contexto + proveedor + hook

La regla: useOutletContext para lo local de la ruta; contexto de React para lo global. Pasar el usuario por useOutletContext desde Diseno sería técnicamente posible, pero obligaría a que cada nivel intermedio lo reenviase: la perforación de props que 05-04 vino a eliminar.

Un aviso de mantenibilidad: useOutletContext no avisa si el padre no ha puesto context; devuelve null y la desestructuración revienta con un mensaje poco informativo. Si el objeto es importante, protégelo como haces con los hooks de contexto del proyecto:

const contexto = useOutletContext();
if (!contexto) {
  throw new Error('PestanaFlota debe renderizarse dentro de PaginaDetalleEstacion');
}

  1. Migas de pan con handle y useMatches

Con el árbol de rutas anidado tienes gratis la información necesaria para unas migas de pan: la cadena de rutas activas ya describe dónde está el usuario. useMatches() devuelve esa cadena, de la raíz a la hoja.

Cada elemento de la cadena tiene:

Propiedad Contenido
id Identificador interno de la ruta ('0-1-2' o el id que le pongas)
pathname La parte de la URL que le corresponde: /estaciones/est-02
params Los parámetros acumulados hasta ahí
handle Lo que tú hayas puesto en la ruta: campo libre
data Lo devuelto por su loader, si lo tiene

handle es la clave: un campo sin significado para React Router y con el significado que tú decidas. En el mapa del apartado 3 ya lo hemos rellenado con { miga: '…' }.

// src/componentes/MigasDePan.jsx
import { useMatches, Link } from 'react-router';
import estilos from './MigasDePan.module.css';

function MigasDePan() {
  const coincidencias = useMatches();

  // Solo interesan las rutas que han declarado una miga
  const migas = coincidencias.filter((c) => Boolean(c.handle?.miga));

  if (migas.length <= 1) return null;   // en la portada no aporta nada

  return (
    <nav aria-label="Migas de pan" className={estilos.migas}>
      <ol>
        {migas.map((coincidencia, indice) => {
          const esUltima = indice === migas.length - 1;

          // La miga puede ser texto o una función de los parámetros
          const texto =
            typeof coincidencia.handle.miga === 'function'
              ? coincidencia.handle.miga(coincidencia.params)
              : coincidencia.handle.miga;

          return (
            <li key={coincidencia.id}>
              {esUltima ? (
                <span aria-current="page">{texto}</span>
              ) : (
                <Link to={coincidencia.pathname}>{texto}</Link>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

export default MigasDePan;

La miga como función permite migas dinámicas que usan los parámetros:

// En src/rutas.jsx
import { estaciones } from './datos/dominio.js';

{
  path: ':estacionId',
  element: <PaginaDetalleEstacion />,
  handle: {
    miga: (params) =>
      estaciones.find((est) => est.id === params.estacionId)?.nombre ?? 'Estación'
  },
  children: [ /* … */ ]
}

Con eso, en /estaciones/est-02/incidencias las migas dicen Inicio › Estaciones › Parque Norte, y cada una es un enlace real. Coloca <MigasDePan /> en Diseno, justo encima del <Outlet />, y funcionará en todas las pantallas sin tocar ninguna:

<main className={estilos.principal}>
  <MigasDePan />
  <Outlet />
</main>

Esta es la ventaja del modo de datos que anunciaba 06-01: el mapa de rutas es un dato, y por tanto se puede recorrer para generar interfaz. Con <Routes> y <Route> en JSX esto sería mucho más incómodo.

  1. Errores por rama: errorElement y useRouteError

En 04-05 construiste LimiteDeError, la única clase del proyecto, capaz de capturar los errores de render de su subárbol. El modo de datos de React Router añade un mecanismo paralelo y más fino: cada ruta puede declarar su propio errorElement.

{
  path: '/',
  element: <Diseno />,
  errorElement: <PaginaErrorRuta />,   // ← captura los fallos de toda esta rama
  children: [ /* … */ ]
}

Cuando algo falla dentro de esa rama, React Router sustituye el elemento de la ruta más cercana que tenga errorElement por ese elemento, dejando intactos sus ancestros. El componente lee el error con useRouteError():

// src/paginas/PaginaErrorRuta.jsx
import { useRouteError, isRouteErrorResponse, Link } from 'react-router';
import Aviso from '../componentes/Aviso.jsx';
import { registrarError } from '../utilidades/monitorizacion.js';

function PaginaErrorRuta() {
  const error = useRouteError();

  // Caso 1: una Response lanzada a propósito (404, 403…)
  if (isRouteErrorResponse(error)) {
    return (
      <section>
        <Aviso tono="error" titulo={`Error ${error.status}: ${error.statusText}`}>
          <p>{error.data || 'No hemos podido completar la operación.'}</p>
        </Aviso>
        <Link to="/">Volver al catálogo</Link>
      </section>
    );
  }

  // Caso 2: un error de JavaScript no previsto
  registrarError(error, 'errorElement de la ruta raíz');

  return (
    <section>
      <Aviso tono="error" titulo="Algo ha fallado en esta pantalla">
        <p>
          El equipo ya ha recibido el aviso. Puedes volver al catálogo y seguir
          usando CicloUrbano con normalidad.
        </p>
        {import.meta.env.DEV && <pre>{error?.message ?? String(error)}</pre>}
      </Aviso>
      <Link to="/">Volver al catálogo</Link>
    </section>
  );
}

export default PaginaErrorRuta;

Puntos importantes:

  • isRouteErrorResponse distingue un error «esperado» —una Response que has lanzado tú— de una excepción de JavaScript. Los dos casos merecen mensajes distintos: el primero es una situación prevista; el segundo, un fallo.
  • Lanzar una Response es la tercera estrategia que 06-02 mencionaba para un recurso inexistente:
const bicicleta = bicicletas.find((bici) => bici.id === bicicletaId);
if (!bicicleta) {
  throw new Response('Bicicleta no encontrada', { status: 404 });
}
  • El detalle técnico solo en desarrollo. import.meta.env.DEV es la variable de Vite (01-02). En producción, el mensaje al usuario; la traza, a registrarError.
  • errorElement por rama. Si pones uno en la ruta estaciones, un fallo en el detalle de una estación se contiene ahí: la cabecera, el pie y las migas siguen vivos, y el usuario puede navegar a otra sección sin recargar.

  1. errorElement frente a LimiteDeError

Tienes dos mecanismos de contención y conviene saber qué cubre cada uno. No compiten: se complementan.

LimiteDeError (04-05) errorElement (React Router)
Qué es Componente de clase propio Propiedad de una ruta
Dónde se coloca En cualquier punto del árbol En una ruta del mapa
Captura errores de render Sí, dentro de su rama
Captura errores de loader/action No
Captura Response lanzadas No las distingue Sí, con isRouteErrorResponse
Captura errores de manejadores de eventos No No
Captura errores asíncronos (setTimeout, promesas) No No
Se reinicia al navegar No, hay que reintentar a mano : al cambiar de ruta desaparece
Alcance típico La aplicación entera, o un panel concreto Una sección de la aplicación
Disponible en modo declarativo No: solo en el modo de datos

Esa fila de «se reinicia al navegar» es la ventaja práctica más notable. Con LimiteDeError, una vez capturado el fallo el subárbol queda sustituido hasta que alguien pulse «Reintentar». Con errorElement, basta con que el usuario navegue a otra ruta para que todo vuelva a la normalidad, que es el comportamiento que espera.

La configuración recomendada para CicloUrbano combina ambos:

flowchart TD
    RAIZ["main.jsx"] --> LE["LimiteDeError GLOBAL<br/><i>red de seguridad última:<br/>fallos del propio enrutador</i>"]
    LE --> PROV["ProveedorTema · ProveedorUsuario"]
    PROV --> RP["RouterProvider"]
    RP --> R0["Ruta / → Diseno<br/><b>errorElement: PaginaErrorRuta</b>"]
    R0 --> R1["Ruta estaciones<br/><i>puede tener su propio errorElement</i>"]
    R0 --> R2["Ruta reservas"]
    R1 --> R3["PaginaDetalleEstacion 💥"]
    R3 -. "el error sube al<br/>errorElement más cercano" .-> R1
    style LE fill:#fde68a
    style R0 fill:#e0f2fe
    style R3 fill:#fecaca

Y sigue vigente la limitación de 04-05, que ninguno de los dos resuelve: los errores lanzados dentro de un manejador de eventos no los captura nadie. Ahí sigue haciendo falta try/catch y un aviso al usuario a través de useAvisos.

  1. Nota sobre loader y action

El modo de datos se llama así por dos propiedades de ruta que esta lección no desarrolla:

{
  path: 'estaciones/:estacionId',
  element: <PaginaDetalleEstacion />,
  loader: async ({ params }) => obtenerEstacion(params.estacionId),  // datos ANTES de renderizar
  action: async ({ request }) => guardarIncidencia(await request.formData())  // envíos
}

La idea es potente: el loader carga los datos antes de pintar la pantalla, y así se evita el patrón «renderizar, lanzar un efecto, mostrar un indicador de carga, repintar» con el que has trabajado desde 05-02, junto con las cascadas de peticiones que produce. useLoaderData() recupera el resultado en la página.

No lo usaremos aquí por una razón de orden: es una decisión de arquitectura de datos, no de enrutamiento, y compite con las bibliotecas de estado del servidor. En 07-06 compararás loader/action con TanStack Query y decidirás; y en 10-01, con Next.js, verás la versión de esa misma idea llevada al servidor. Por ahora, CicloUrbano sigue leyendo de dominio.js y de useFetchBicicletas, que es suficiente y mantiene el foco.

Errores Comunes y Consejos

Olvidar <Outlet /> en el componente padre. El síntoma es exasperante: la ruta coincide, no hay ningún error en consola, y la pantalla hija simplemente no aparece. Si una ruta tiene children, su element debe contener un <Outlet />.

Poner / inicial en el path de una hija. { path: '/estaciones' } dentro del padre { path: '/' } es absoluta: funciona por casualidad, pero deja de funcionar en cuanto muevas el subárbol. Las hijas van sin barra inicial.

Pestañas todas activas a la vez. Le falta end al NavLink de la pestaña índice (to="."). Sin él, la pestaña por defecto se considera activa también en las hermanas, porque su destino es prefijo de todas.

Interpolar la URL completa en las pestañas. to={/estaciones/${estacionId}/incidencias} funciona, pero obliga a arrastrar el identificador y rompe si cambias el mapa. Usa to="incidencias" relativo.

Confundir index: true con una ruta que coincide siempre. La ruta índice se pinta solo cuando la URL coincide exactamente con el padre. En /estaciones/est-02 la índice de estaciones no está activa.

Usar useOutletContext sin que el padre pase context. Devuelve null y la desestructuración lanza un error confuso. Comprueba, o al menos documenta el contrato en un comentario.

Recalcular en cada pestaña lo que el padre ya tiene. Si PestanaFlota y PestanaIncidencias vuelven a filtrar bicicletas, duplicas trabajo, duplicas la comprobación de existencia y —cuando haya API— duplicas las peticiones. Calcula en el padre y baja por context.

Consejo: refleja en la URL lo que el usuario querría compartir. Una pestaña sí (/estaciones/est-02/incidencias tiene sentido enviado por chat); el estado de un acordeón abierto, no. Si el estado no sobrevive a un F5 y a nadie le importa, es useState; si sí importa, es ruta o parámetro de consulta.

Consejo: anida el mapa igual que anidarías los componentes. Si al dibujar la pantalla el marco contiene a la sección y esta a la pestaña, el mapa debe tener esa misma forma. Cuando el mapa y el diseño divergen, aparecen los remontajes y las duplicaciones.

Consejo: usa handle para todo lo declarativo de la ruta. Migas, título del documento, icono del menú, nivel de permiso. Es un campo libre y convierte el mapa en la única fuente de verdad sobre la navegación.

Ejercicios

Ejercicio 1: la sección de reservas anidada

Reorganiza la rama de reservas para que /reservas y /reservas/nueva compartan un marco común, MarcoReservas, que muestre un encabezado «Reservas» y un pequeño menú con dos enlaces relativos («Mis reservas» y «Nueva reserva») marcando el activo. Requisitos:

  • Ninguna URL debe cambiar: siguen siendo /reservas y /reservas/nueva.
  • MarcoReservas no debe volver a montarse al pasar de una a otra.
  • El enlace «Mis reservas» no debe aparecer activo estando en /reservas/nueva.

Ejercicio 2: título del documento desde el mapa

Usando handle y useMatches, escribe un hook useTituloDeRuta que ponga en document.title un texto compuesto por la miga de la ruta más profunda y el nombre de la aplicación, por ejemplo Parque Norte · CicloUrbano. Debe actualizarse en cada navegación y usar el valor por defecto CicloUrbano cuando ninguna ruta declare miga.

Ejercicio 3: contener un fallo en una rama

La pestaña de incidencias tiene un fallo: si una bicicleta no tiene estado, bici.estado.toUpperCase() lanza. Configura el mapa para que ese fallo no tumbe la pantalla de detalle de la estación —el título, las plazas y las pestañas deben seguir visibles— sino solo el contenido de la pestaña. Explica qué rutas necesitan errorElement y por qué no basta con el de la ruta raíz.

Soluciones

Solución 1

// src/rutas.jsx — fragmento de la rama de reservas
{
  path: 'reservas',
  element: <MarcoReservas />,          // ← ahora sí tiene elemento
  handle: { miga: 'Reservas' },
  children: [
    { index: true, element: <PaginaReservas /> },
    { path: 'nueva', element: <PaginaNuevaReserva />, handle: { miga: 'Nueva reserva' } }
  ]
}
// src/componentes/MarcoReservas.jsx
import { NavLink, Outlet } from 'react-router';
import { useReservas } from '../contextos/ContextoReservas.jsx';
import estilos from './MarcoReservas.module.css';

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

  const clases = ({ isActive }) =>
    isActive ? `${estilos.enlace} ${estilos.activo}` : estilos.enlace;

  return (
    <section>
      <h2>Reservas</h2>
      <p>{estado.reservas.length} reservas registradas</p>

      <nav aria-label="Secciones de reservas">
        <NavLink to="." end className={clases}>
          Mis reservas
        </NavLink>
        <NavLink to="nueva" className={clases}>
          Nueva reserva
        </NavLink>
      </nav>

      <Outlet />
    </section>
  );
}

export default MarcoReservas;

Las tres claves: las hijas conservan index: true y path: 'nueva', así que las URLs no cambian; MarcoReservas es el element del padre y por tanto persiste al navegar entre hijas, cambiando solo el <Outlet />; y el end en to="." evita que «Mis reservas» aparezca activo en /reservas/nueva, porque sin él su destino sería prefijo del de la hermana.

Solución 2

// src/hooks/useTituloDeRuta.js
import { useEffect } from 'react';
import { useMatches } from 'react-router';

const NOMBRE_APP = 'CicloUrbano';

/**
 * Sincroniza document.title con la miga de la ruta activa más profunda.
 * Sin parámetros. No devuelve nada.
 */
export function useTituloDeRuta() {
  const coincidencias = useMatches();

  const conMiga = coincidencias.filter((c) => Boolean(c.handle?.miga));
  const ultima = conMiga.at(-1);

  const texto =
    typeof ultima?.handle.miga === 'function'
      ? ultima.handle.miga(ultima.params)
      : ultima?.handle.miga;

  useEffect(() => {
    // Sincronización con un sistema externo (el documento): caso de libro de useEffect (05-02)
    document.title = texto ? `${texto} · ${NOMBRE_APP}` : NOMBRE_APP;
  }, [texto]);
}

Se llama una sola vez, desde Diseno, y funciona en todas las pantallas:

function Diseno() {
  useTituloDeRuta();
  // …
}

Dos comentarios. Primero, la dependencia del efecto es texto, una cadena, no el array coincidencias: este último es un objeto nuevo en cada render y provocaría que el efecto se ejecutase siempre, el problema de dependencias de 05-02. Segundo, esto es exactamente la definición de efecto que fijamos en aquella lección: sincronizar React con un sistema externo, en este caso el título del documento.

Solución 3

Hace falta un errorElement en las rutas de las pestañas, no solo en la raíz:

{
  path: ':estacionId',
  element: <PaginaDetalleEstacion />,
  children: [
    { index: true, element: <PestanaFlota />, errorElement: <ErrorPestana /> },
    { path: 'incidencias', element: <PestanaIncidencias />, errorElement: <ErrorPestana /> }
  ]
}
// src/paginas/ErrorPestana.jsx
import { useRouteError } from 'react-router';
import Aviso from '../componentes/Aviso.jsx';
import { registrarError } from '../utilidades/monitorizacion.js';

function ErrorPestana() {
  const error = useRouteError();
  registrarError(error, 'pestaña de detalle de estación');

  return (
    <Aviso tono="error" titulo="No hemos podido mostrar esta pestaña">
      <p>Los datos de la estación siguen disponibles en la otra pestaña.</p>
    </Aviso>
  );
}

export default ErrorPestana;

Por qué no basta con el errorElement de la raíz: React Router busca el errorElement de la ruta que ha fallado y, si no lo tiene, sube por sus ancestros hasta encontrar uno, sustituyendo el elemento de esa ruta. Si el más cercano es el de la raíz, lo que se sustituye es <Diseno /> entero: desaparecen cabecera, migas, pie, título de la estación y pestañas, y el usuario se queda mirando una pantalla de error completa por un fallo que afecta a un recuadro. Poniendo el errorElement en la propia pestaña, la sustitución ocurre exactamente donde estaba el <Outlet /> de PaginaDetalleEstacion: el resto de la pantalla sigue intacto y el usuario puede cambiar a la otra pestaña.

Es la misma lógica de granularidad de 04-05 con los LimiteDeError alrededor del catálogo y del formulario, ahora expresada en el mapa de rutas en vez de en el JSX. Y viene con un extra: al navegar a otra ruta, el estado de error se limpia solo.

Conclusión

Las rutas anidadas son composición de interfaz expresada a través de la URL: una ruta padre declara children y abre un hueco con <Outlet />, y la hija que coincide con la URL se pinta ahí. Has convertido Diseno en la ruta raíz de CicloUrbano, y la ganancia es concreta y medible: Cabecera y PieDePagina dejan de desmontarse y volver a montarse en cada navegación, sus efectos no se repiten, su estado interno persiste, y los proveedores que viven ahí —ProveedorReservas y ProveedorAvisos— sobreviven a los cambios de pantalla, que es lo que permitirá crear una reserva en una pantalla y verla en otra. Sabes que index: true marca la pantalla por defecto de una sección, en qué se diferencia de path: '', y que una ruta sin path agrupa varias pantallas bajo un mismo marco sin añadir ningún segmento a la URL, patrón del que 06-05 hará su herramienta principal.

Has construido el detalle de estación con pestañas anidadas: flota como ruta índice e incidencias como hermana, con NavLink relativos —to="." con end y to="incidencias"— que marcan la pestaña activa sin interpolar el identificador, de modo que cada pestaña tiene su propia dirección compartible y el botón atrás alterna entre ellas. useOutletContext te ha permitido calcular la estación y su flota una sola vez en el padre y bajarlas a las pestañas, reservando el contexto de React para lo verdaderamente ambiental. useMatches junto con el campo libre handle ha convertido el mapa de rutas en la fuente de las migas de pan y del título del documento, la primera muestra clara de por qué en el modo de datos conviene que el mapa sea un dato y no marcado. Y errorElement con useRouteError te da contención de fallos por rama, complementaria al LimiteDeError de 04-05: aquel es la red de seguridad global y la única que existe fuera del enrutador; este captura además los errores de loader/action, distingue las Response lanzadas a propósito con isRouteErrorResponse y —ventaja nada menor— se limpia solo al navegar.

Toda la navegación que has escrito hasta aquí nace de un clic en un enlace. Pero hay situaciones en las que la aplicación debe navegar por su cuenta: cuando el usuario confirma el FormularioReserva en /reservas/nueva, lo correcto es crear la reserva, despacharla al reductorReservas y llevarlo a /reservas sin que el formulario quede en el historial; cuando alguien intenta abandonar un formulario a medias, conviene detenerlo y preguntar; y mientras una navegación está en curso, conviene decírselo. La próxima lección es Navegación Programática.

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