The previous lesson left CicloUrbano routed but maimed: every screen paints itself alone, with no Header and no Footer, because Layout was left out of the route map. The obvious fix — wrapping every page in <Layout> — works and is a bad idea: it repeats the same markup nine times, remounts the header on every navigation, and stops the frame from sharing data with the screen. React Router offers something better: routes nest just like components, so a parent route paints the frame and reserves a slot where the active child route appears. In this lesson you'll learn that mechanism with children and <Outlet />, turn Layout into the root route, tell index routes apart from routes with path: '', use pathless routes to group screens without cluttering the URL, build the station detail page with the flota and incidencias tabs, pass data from parent to child with useOutletContext, generate breadcrumbs straight from the route map with handle and useMatches, and handle errors per branch with errorElement.

Contents

  1. The idea: composing the UI through the URL
  2. children and <Outlet />
  3. Layout as the root route: what exactly improves
  4. Index routes: index: true
  5. index: true vs. path: ''
  6. Pathless routes: grouping without adding a segment
  7. Relative and absolute routes
  8. Full case: station detail page with tabs
  9. useOutletContext: parent data for the child
  10. Breadcrumbs with handle and useMatches
  11. Errors per branch: errorElement and useRouteError
  12. errorElement vs. ErrorBoundary
  13. A note on loader and action

  1. The idea: composing the UI through the URL

Look again at a CicloUrbano URL and read it piece by piece:

/estaciones/est-02/incidencias
 └────┬───┘ └──┬──┘ └────┬────┘
      │        │         └─ tab inside the detail page
      │        └─ which station
      └─ application section

Each segment scopes the one before it. And if you look closely, the UI that corresponds to that URL has exactly the same Russian-doll structure: the application frame contains the stations screen, which contains the detail of est-02, which contains the incidents tab.

Nesting routes means declaring that one route is the child of another, so that when the child is active the parent also renders, and the child appears inside it.

That correspondence between the URL hierarchy and the component hierarchy is what makes React routing fit so naturally with what you already know about composition (04-02): a nested route map is a component tree, written a different way.

flowchart TD
    subgraph URL["URL: /estaciones/est-02/incidencias"]
        S1["/estaciones"] --> S2["/est-02"] --> S3["/incidencias"]
    end
    subgraph UI["Rendered UI"]
        C0["Layout<br/>Header + Outlet + Footer"]
        C0 --> C1["StationDetailPage<br/>title + tabs + Outlet"]
        C1 --> C2["IncidentsTab"]
    end
    S1 -.-> C0
    S2 -.-> C1
    S3 -.-> C2
    style C0 fill:#e0f2fe
    style C1 fill:#fef9c3
    style C2 fill:#dcfce7

  1. children and <Outlet />

Two pieces, and that's it. In the map, the children property; in the parent component, the <Outlet /> component.

// src/routes.jsx — the simplest form of nesting
import { createBrowserRouter } from 'react-router';
import Layout from './components/Layout.jsx';
import CataloguePage from './pages/CataloguePage.jsx';
import StationsPage from './pages/StationsPage.jsx';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,          // ← ALWAYS renders
    children: [
      { index: true, element: <CataloguePage /> },
      { path: 'estaciones', element: <StationsPage /> }
    ]
  }
]);
// src/components/Layout.jsx — from children to Outlet
import { Outlet } from 'react-router';
import Header from './Header.jsx';
import Footer from './Footer.jsx';
import styles from './Layout.module.css';

function Layout() {
  return (
    <div className={styles.layout}>
      <Header />
      <main className={styles.main}>
        <Outlet />          {/* ← the active child route renders here */}
      </main>
      <Footer />
    </div>
  );
}

export default Layout;

Compare it with the 04-02 version, which received { children }:

Layout with children (04-02) Layout with <Outlet /> (now)
Who decides the content Whoever writes <Layout>…</Layout> The router, based on the URL
Signature function Layout({ children }) function Layout()
Where it's used Manually, in App As a route's element
When the content changes When the parent re-renders On navigation

<Outlet /> is the router's children. It's the same slot idea from 04-02, filled in automatically by whichever child route matches the URL. Note that Layout no longer accepts children: if you ever need both — and sometimes you do — you can have them side by side, but CicloUrbano doesn't need to.

And the rules that govern nesting:

  • A child's path is relative to its parent and doesn't start with /. The child path: 'estaciones' inside the parent path: '/' produces the URL /estaciones. Writing path: '/estaciones' in the child also works (it's absolute), but it breaks the pattern and makes reorganising the tree harder.
  • If the parent has no <Outlet />, the child doesn't show. It's the most common mistake in this lesson: everything is configured correctly, the URL matches, but nothing appears on screen because the slot is missing.
  • Nesting has no depth limit, although more than three or four levels usually means the URL model has gotten too complicated.

  1. Layout as the root route: what exactly improves

Here's the full CicloUrbano map, now nested, replacing the 06-02 blueprint:

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

import Layout from './components/Layout.jsx';
import CataloguePage from './pages/CataloguePage.jsx';
import BikeDetailPage from './pages/BikeDetailPage.jsx';
import StationsPage from './pages/StationsPage.jsx';
import StationDetailPage from './pages/StationDetailPage.jsx';
import FleetTab from './pages/FleetTab.jsx';
import IncidentsTab from './pages/IncidentsTab.jsx';
import BookingsPage from './pages/BookingsPage.jsx';
import NewBookingPage from './pages/NewBookingPage.jsx';
import SignInPage from './pages/SignInPage.jsx';
import WorkshopPage from './pages/WorkshopPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import RouteErrorPage from './pages/RouteErrorPage.jsx';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    errorElement: <RouteErrorPage />,
    handle: { crumb: 'Home' },
    children: [
      { index: true, element: <CataloguePage />, handle: { crumb: 'Catalogue' } },

      {
        path: 'bicicletas/:bicicletaId',
        element: <BikeDetailPage />,
        handle: { crumb: 'Bike details' }
      },

      {
        path: 'estaciones',
        handle: { crumb: 'Stations' },
        children: [
          { index: true, element: <StationsPage /> },
          {
            path: ':estacionId',
            element: <StationDetailPage />,
            handle: { crumb: 'Station details' },
            children: [
              { index: true, element: <FleetTab /> },
              { path: 'incidencias', element: <IncidentsTab /> }
            ]
          }
        ]
      },

      {
        path: 'reservas',
        handle: { crumb: 'My bookings' },
        children: [
          { index: true, element: <BookingsPage /> },
          { path: 'nueva', element: <NewBookingPage />, handle: { crumb: 'New booking' } }
        ]
      },

      { path: 'acceso', element: <SignInPage />, handle: { crumb: 'Sign in' } },
      { path: 'taller', element: <WorkshopPage />, handle: { crumb: 'Workshop' } },

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

Notice that the estaciones route has no element: it's only there to group. When a route has children and no element, React Router implicitly renders an <Outlet /> in its place, so the children rise straight into the grandparent's slot. It's a clean way to organise the map by section.

What exactly improves by making Layout the root route instead of wrapping every page:

<Layout> around every page Layout as the root route
Markup repetition Nine times Once
On navigation between screens Layout, Header and Footer unmount and remount They persist: React only changes what's inside <Outlet />
State inside the header (open menu, search box) Lost on every navigation Preserved
Header effects Their cleanup and setup run on every click Run once
Scroll position of a long sidebar Jumps back to the top Stays put
Providers placed there (NoticesProvider, BookingsProvider) Reset: notices and bookings are lost Survive navigation
Header entrance animations Replay annoyingly Don't replay

The providers row matters the most, and it settles a decision left open in 06-02. BookingsProvider and NoticesProvider go inside Layout, not in main.jsx:

// src/components/Layout.jsx — final version
import { Outlet } from 'react-router';
import { BookingsProvider } from '../contexts/BookingsContext.jsx';
import { NoticesProvider } from '../contexts/NoticesContext.jsx';
import Header from './Header.jsx';
import Footer from './Footer.jsx';
import NoticeList from './NoticeList.jsx';
import styles from './Layout.module.css';

function Layout() {
  return (
    <BookingsProvider>
      <NoticesProvider>
        <div className={styles.layout}>
          <Header />
          <NoticeList />           {/* notices are visible from any screen */}
          <main className={styles.main}>
            <Outlet />
          </main>
          <Footer />
        </div>
      </NoticesProvider>
    </BookingsProvider>
  );
}

export default Layout;

The reasoning is the same as in 06-02, one level down: ThemeProvider and UserProvider go outside the router because they must survive absolutely everything; BookingsProvider and NoticesProvider go on the root route because they belong to the application frame, several screens consume them, and this keeps them next to the UI that uses them. Since Layout doesn't unmount on navigation, the practical effect is the same and the map reads better. This detail is exactly what lets you create a booking on /reservas/nueva in 06-04 and see it show up on /reservas: if the provider reset on every screen change, the booking would have evaporated along the way.

  1. Index routes: index: true

Question: with the parent path: 'estaciones' and the child path: ':estacionId', what shows on plain /estaciones? Nothing inside the <Outlet />: no child matches.

That's what the index route is for:

An index route (index: true) is the child that renders in the parent's <Outlet /> when the URL matches the parent exactly, with no further segments.

{
  path: 'estaciones',
  children: [
    { index: true, element: <StationsPage /> },      // → /estaciones
    { path: ':estacionId', element: <StationDetailPage /> }  // → /estaciones/est-02
  ]
}
URL Active child route What shows in the <Outlet />
/estaciones the index route StationsPage
/estaciones/est-02 :estacionId StationDetailPage
/estaciones/est-99 :estacionId StationDetailPage → "not found"

Its rules:

  • An index route has no path. { index: true, path: 'algo' } is an error.
  • An index route can't have children. By definition it's a leaf of the tree.
  • There can only be one per parent.
  • It's that section's default screen, which is where the pattern gets its name: it's the "index" of the folder, like a web server's index.html for a directory.

CicloUrbano has three: the catalogue under the root, the list under estaciones, and the fleet tab under the station detail page.

  1. index: true vs. path: ''

There's an alternative that looks equivalent and isn't quite:

{ index: true, element: <StationsPage /> }
{ path: '', element: <StationsPage /> }   // the same thing?

Both match the parent's URL. The differences:

index: true path: ''
Matches the parent's URL Yes Yes
Can have children No Yes
Declared intent "This is the default screen" "This route adds a frame without changing the URL"
Behaviour in relative links Behaves like the parent Counts as one more level
Recommendation Use it for default screens Only if you need an extra nesting level with no segment

In practice: use index: true almost always. path: '' is the tool for one specific case — inserting an intermediate component that also has children of its own — and there it competes with the pathless route from the next section, which usually expresses it better.

One warning: path: '/' on a child route is not the same as path: ''. The slash makes it absolute and produces surprising matches. Inside a parent, keep children always relative.

  1. Pathless routes: grouping without adding a segment

This pattern solves a very specific problem: applying a shared frame to several screens without the URL reflecting it.

A pathless route is a route with element and children but no path. It doesn't consume any URL segment: it only inserts its element into the tree and lets the children carry on as if it weren't there.

{
  path: '/',
  element: <Layout />,
  children: [
    { index: true, element: <CataloguePage /> },

    // Route WITHOUT path: wraps, but doesn't show up in the URL
    {
      element: <ProtectedRoute />,
      children: [
        { path: 'taller', element: <WorkshopPage /> },        // → /taller
        { path: 'informes', element: <ReportsPage /> }      // → /informes
      ]
    }
  ]
}

Notice the result: /taller stays /taller, not /protegido/taller. The ProtectedRoute component renders between Layout and WorkshopPage, checks the session, and decides whether to render its <Outlet /> or redirect. This is pattern 2 from 06-05, and you'll see it developed there.

Other common uses:

Use What it groups
Protection Several screens behind the same session guard
Error handling Several screens under the same errorElement
Secondary frame A sidebar shared by three screens whose URLs don't share a prefix
Scoped provider A context that should only exist for one group of screens

The mental rule: path is for the URL; nesting is for the UI. Two screens sharing a frame doesn't force them to share a prefix, and a pathless route is exactly the way to decouple the two.

  1. Relative and absolute routes

In path and in to, a string that starts with / is absolute, and one that doesn't is relative. The difference matters more than it looks.

In path (inside the map):

{
  path: 'estaciones',
  children: [
    { path: ':estacionId', … },    // ✅ relative → /estaciones/est-02
    { path: '/estaciones/:estacionId', … }  // ⚠️ absolute: works, but fragile
  ]
}

The relative form is recommended because if you rename the section to paradas tomorrow, you change one line and the whole subtree still works. With absolute routes you'd have to edit every child.

In to (inside components): the reference is the active route, not the literal URL. While on /estaciones/est-02:

to Destination Comment
"/" / Absolute: from the root
"/estaciones" /estaciones Absolute
"incidencias" /estaciones/est-02/incidencias Relative: appended at the end
".." /estaciones Goes up one route level
"../est-01" /estaciones/est-01 Up and back down

The .. case has a subtlety worth knowing: by default it goes up one level in the route hierarchy, not in URL segments. If you want filesystem-style segment behaviour, you ask for it explicitly:

<Link to=".." relative="path">Go up one URL segment</Link>

Practical tip: use relative routes inside a subtree (a screen's tabs, a section's internal navigation) and absolute ones for the main navigation (the Header menu). Relative links let a subtree move around without touching its links; absolute ones make the destination clear in the global menu.

  1. Full case: station detail page with tabs

Now for the central case of the lesson. /estaciones/:estacionId shows the station's data and two tabs: fleet (the default) and incidents. Each tab is a route, so it has its own URL, can be shared, and the back button toggles between them.

// src/pages/StationDetailPage.jsx
import { useParams, NavLink, Outlet, Link } from 'react-router';
import { stations, bikes } from '../data/domain.js';
import NotFoundPage from './NotFoundPage.jsx';
import styles from './StationDetailPage.module.css';

function StationDetailPage() {
  const { estacionId } = useParams();
  const station = stations.find((st) => st.id === estacionId);

  if (!station) {
    return <NotFoundPage resource="station" id={estacionId} />;
  }

  // Computed ONCE here and shared with the tabs
  const fleet = bikes.filter((bike) => bike.stationId === station.id);

  const tabClasses = ({ isActive }) =>
    isActive ? `${styles.tab} ${styles.active}` : styles.tab;

  return (
    <article>
      <p className={styles.backLink}>
        <Link to="/estaciones">← Stations</Link>
      </p>

      <h2>{station.name}</h2>
      <p>
        {station.district} district · {station.docks} docks ·{' '}
        {fleet.length} bikes assigned
      </p>

      {/* The tabs are relative NavLinks: they don't repeat the station id */}
      <nav className={styles.tabs} aria-label="Station sections">
        <NavLink to="." end className={tabClasses}>
          Fleet
        </NavLink>
        <NavLink to="incidencias" className={tabClasses}>
          Incidents
        </NavLink>
      </nav>

      {/* The active tab renders here, and receives the fleet already computed */}
      <Outlet context={{ station, fleet }} />
    </article>
  );
}

export default StationDetailPage;

Details that make this work:

  • to="." points to the active route itself, i.e. /estaciones/est-02. With end it demands an exact match, so while on /incidencias the "Fleet" tab doesn't show as marked. Without end, both would show as active at once, which is the classic mistake with nested tabs.
  • to="incidencias" is relative: it's appended to the end of the current route. Never write /estaciones/est-02/incidencias here, because you'd have to interpolate the id by hand and the component would stop being reusable.
  • The fleet is computed once in the parent and flows down to the children through context, instead of each tab filtering the array again. That's the next section.
  • Navigating between tabs doesn't remount the station header: the <article>, the title and the tabs persist; only what's inside the <Outlet /> changes. Exactly the same win as with Layout, one level down.
flowchart TD
    D["Layout<br/><i>Header · Outlet · Footer</i>"]
    D --> P["StationDetailPage<br/><i>title · tabs · Outlet</i>"]
    P --> F["FleetTab<br/><b>index route</b><br/>/estaciones/est-02"]
    P --> I["IncidentsTab<br/>/estaciones/est-02/incidencias"]
    style D fill:#e0f2fe
    style P fill:#fef9c3
    style F fill:#dcfce7
    style I fill:#dcfce7

  1. useOutletContext: parent data for the child

Real problem: FleetTab and IncidentsTab need the station and its fleet. They could read useParams() and search domain.js again, but that would duplicate the work, duplicate the "not found" check too, and — once the data comes from an API in 07-06 — duplicate the request.

<Outlet /> accepts a context prop with whatever you want to pass down, and the child reads it with useOutletContext():

// src/pages/FleetTab.jsx
import { useOutletContext, Link } from 'react-router';
import StatusBadge from '../components/StatusBadge.jsx';

function FleetTab() {
  const { station, fleet } = useOutletContext();

  if (fleet.length === 0) {
    return <p>No bikes are assigned to {station.name} right now.</p>;
  }

  return (
    <ul>
      {fleet.map((bike) => (
        <li key={bike.id}>
          <Link to={`/bicicletas/${bike.id}`}>{bike.model}</Link>{' '}
          <StatusBadge status={bike.status} />{' '}
          <span>€{bike.pricePerHour.toFixed(2)} / hour</span>
        </li>
      ))}
    </ul>
  );
}

export default FleetTab;
// src/pages/IncidentsTab.jsx
import { useOutletContext } from 'react-router';
import Notice from '../components/Notice.jsx';

function IncidentsTab() {
  const { station, fleet } = useOutletContext();

  const inMaintenance = fleet.filter((bike) => bike.status === 'mantenimiento');

  if (inMaintenance.length === 0) {
    return <p>{station.name} has no open incidents.</p>;
  }

  return (
    <ul>
      {inMaintenance.map((bike) => (
        <li key={bike.id}>
          <Notice tone="warning" title={`${bike.model} in maintenance`}>
            <p>Bike {bike.id} is withdrawn from service at {station.name}.</p>
          </Notice>
        </li>
      ))}
    </ul>
  );
}

export default IncidentsTab;

useOutletContext vs. useContext — a fair question to ask after 05-04:

useOutletContext React context (useContext)
Scope Only the direct child route of the <Outlet /> The provider's whole subtree
How it's declared <Outlet context={…} /> <XProvider> with createContext
When to use it Data for this screen, for its tabs Ambient data for half the app
Example in CicloUrbano { station, fleet } user, theme, notices, bookings
Verbosity None: one prop Context file + provider + hook

The rule: useOutletContext for what's local to the route; React context for what's global. Passing the user through useOutletContext from Layout would be technically possible, but it would force every intermediate level to forward it: the prop drilling that 05-04 came to eliminate.

A maintainability warning: useOutletContext doesn't warn you if the parent hasn't set context; it returns null and the destructuring blows up with an unhelpful message. If the object matters, guard it the way you do with the project's context hooks:

const context = useOutletContext();
if (!context) {
  throw new Error('FleetTab must render inside StationDetailPage');
}

  1. Breadcrumbs with handle and useMatches

With the nested route tree, you get the information you need for breadcrumbs for free: the chain of active routes already describes where the user is. useMatches() returns that chain, from root to leaf.

Each item in the chain has:

Property Content
id The route's internal identifier ('0-1-2', or the id you set)
pathname The part of the URL it corresponds to: /estaciones/est-02
params The parameters accumulated up to that point
handle Whatever you put on the route: a free-form field
data What its loader returned, if it has one

handle is the key: a field with no meaning to React Router, and whatever meaning you decide. In section 3's map we've already filled it with { crumb: '…' }.

// src/components/Breadcrumbs.jsx
import { useMatches, Link } from 'react-router';
import styles from './Breadcrumbs.module.css';

function Breadcrumbs() {
  const matches = useMatches();

  // Only the routes that have declared a crumb matter
  const crumbs = matches.filter((m) => Boolean(m.handle?.crumb));

  if (crumbs.length <= 1) return null;   // adds nothing on the home screen

  return (
    <nav aria-label="Breadcrumbs" className={styles.breadcrumbs}>
      <ol>
        {crumbs.map((match, index) => {
          const isLast = index === crumbs.length - 1;

          // The crumb can be text or a function of the parameters
          const text =
            typeof match.handle.crumb === 'function'
              ? match.handle.crumb(match.params)
              : match.handle.crumb;

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

export default Breadcrumbs;

A crumb as a function enables dynamic crumbs that use the parameters:

// In src/routes.jsx
import { stations } from './data/domain.js';

{
  path: ':estacionId',
  element: <StationDetailPage />,
  handle: {
    crumb: (params) =>
      stations.find((st) => st.id === params.estacionId)?.name ?? 'Station'
  },
  children: [ /* … */ ]
}

With that, on /estaciones/est-02/incidencias the breadcrumbs read Home › Stations › North Park, and each one is a real link. Place <Breadcrumbs /> in Layout, right above the <Outlet />, and it'll work on every screen without touching a single one of them:

<main className={styles.main}>
  <Breadcrumbs />
  <Outlet />
</main>

This is the advantage of the data mode that 06-01 promised: the route map is data, and so it can be traversed to generate UI. With <Routes> and <Route> in JSX this would be a lot more awkward.

  1. Errors per branch: errorElement and useRouteError

In 04-05 you built ErrorBoundary, the project's only class, able to catch render errors in its subtree. React Router's data mode adds a parallel, finer-grained mechanism: each route can declare its own errorElement.

{
  path: '/',
  element: <Layout />,
  errorElement: <RouteErrorPage />,   // ← catches failures across this whole branch
  children: [ /* … */ ]
}

When something fails inside that branch, React Router replaces the element of the nearest route that has an errorElement with that element, leaving its ancestors untouched. The component reads the error with useRouteError():

// src/pages/RouteErrorPage.jsx
import { useRouteError, isRouteErrorResponse, Link } from 'react-router';
import Notice from '../components/Notice.jsx';
import { reportError } from '../utils/monitoring.js';

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

  // Case 1: a Response thrown on purpose (404, 403…)
  if (isRouteErrorResponse(error)) {
    return (
      <section>
        <Notice tone="error" title={`Error ${error.status}: ${error.statusText}`}>
          <p>{error.data || "We couldn't complete the operation."}</p>
        </Notice>
        <Link to="/">Back to the catalogue</Link>
      </section>
    );
  }

  // Case 2: an unexpected JavaScript error
  reportError(error, 'root route errorElement');

  return (
    <section>
      <Notice tone="error" title="Something went wrong on this screen">
        <p>
          The team has already been notified. You can go back to the catalogue and
          keep using CicloUrbano as usual.
        </p>
        {import.meta.env.DEV && <pre>{error?.message ?? String(error)}</pre>}
      </Notice>
      <Link to="/">Back to the catalogue</Link>
    </section>
  );
}

export default RouteErrorPage;

Important points:

  • isRouteErrorResponse tells apart an "expected" error — a Response you threw yourself — from a JavaScript exception. The two cases deserve different messages: the first is a foreseen situation; the second, a failure.
  • Throwing a Response is the third strategy 06-02 mentioned for a nonexistent resource:
const bike = bikes.find((b) => b.id === bicicletaId);
if (!bike) {
  throw new Response('Bike not found', { status: 404 });
}
  • Technical detail only in development. import.meta.env.DEV is Vite's variable (01-02). In production, the user gets the message; the stack trace goes to reportError.
  • errorElement per branch. If you put one on the estaciones route, a failure in a station's detail page is contained there: the header, footer and breadcrumbs stay alive, and the user can navigate to another section without reloading.

  1. errorElement vs. ErrorBoundary

You have two containment mechanisms, and it's worth knowing what each one covers. They don't compete: they complement each other.

ErrorBoundary (04-05) errorElement (React Router)
What it is Custom class component A route property
Where it goes Anywhere in the tree On a route in the map
Catches render errors Yes Yes, within its branch
Catches loader/action errors No Yes
Catches thrown Responses Doesn't distinguish them Yes, with isRouteErrorResponse
Catches event-handler errors No No
Catches async errors (setTimeout, promises) No No
Resets on navigation No, needs a manual retry Yes: it disappears when the route changes
Typical scope The whole app, or a specific panel A section of the app
Available in declarative mode Yes No: data mode only

That "resets on navigation" row is the most notable practical advantage. With ErrorBoundary, once the failure is caught the subtree stays replaced until someone clicks "Retry." With errorElement, the user just has to navigate to another route for everything to go back to normal, which is the behaviour they expect.

The recommended setup for CicloUrbano combines both:

flowchart TD
    RAIZ["main.jsx"] --> LE["GLOBAL ErrorBoundary<br/><i>last safety net:<br/>failures in the router itself</i>"]
    LE --> PROV["ThemeProvider · UserProvider"]
    PROV --> RP["RouterProvider"]
    RP --> R0["Route / → Layout<br/><b>errorElement: RouteErrorPage</b>"]
    R0 --> R1["Route estaciones<br/><i>can have its own errorElement</i>"]
    R0 --> R2["Route reservas"]
    R1 --> R3["StationDetailPage 💥"]
    R3 -. "the error bubbles up to<br/>the nearest errorElement" .-> R1
    style LE fill:#fde68a
    style R0 fill:#e0f2fe
    style R3 fill:#fecaca

And the 04-05 limitation still stands, and neither mechanism solves it: errors thrown inside an event handler aren't caught by anything. You still need try/catch there, plus a notice to the user through useNotices.

  1. A note on loader and action

Data mode gets its name from two route properties this lesson doesn't cover in depth:

{
  path: 'estaciones/:estacionId',
  element: <StationDetailPage />,
  loader: async ({ params }) => getStation(params.estacionId),  // data BEFORE rendering
  action: async ({ request }) => saveIncident(await request.formData())  // submissions
}

The idea is powerful: the loader fetches the data before the screen paints, avoiding the "render, fire an effect, show a loading indicator, re-render" pattern you've been working with since 05-02, along with the request waterfalls it produces. useLoaderData() retrieves the result on the page.

We won't use it here, for a reason of sequencing: it's a data-architecture decision, not a routing one, and it competes with server-state libraries. In 07-06 you'll compare loader/action with TanStack Query and decide; and in 10-01, with Next.js, you'll see that same idea taken to the server. For now, CicloUrbano keeps reading from domain.js and from useFetchBikes, which is enough and keeps the focus.

Common Mistakes and Tips

Forgetting <Outlet /> in the parent component. The symptom is maddening: the route matches, there's no error in the console, and the child screen simply doesn't show up. If a route has children, its element must contain an <Outlet />.

Putting a leading / on a child's path. { path: '/estaciones' } inside the parent { path: '/' } is absolute: it works by accident, and stops working the moment you move the subtree. Children go without a leading slash.

All tabs showing active at once. The index tab's NavLink (to=".") is missing end. Without it, the default tab is also considered active on its siblings, because its destination is a prefix of all of them.

Interpolating the full URL in the tabs. to={/estaciones/${estacionId}/incidencias} works, but forces you to drag the identifier along and breaks if you change the map. Use the relative to="incidencias".

Confusing index: true with a route that always matches. The index route only renders when the URL matches the parent exactly. On /estaciones/est-02, estaciones's index isn't active.

Using useOutletContext without the parent passing context. It returns null and the destructuring throws a confusing error. Check for it, or at least document the contract in a comment.

Recomputing in every tab what the parent already has. If FleetTab and IncidentsTab filter bikes again, you duplicate the work, duplicate the existence check, and — once there's an API — duplicate the requests. Compute it in the parent and pass it down through context.

Tip: reflect in the URL whatever the user would want to share. A tab, yes (/estaciones/est-02/incidencias makes sense sent over chat); an open accordion's state, no. If the state doesn't need to survive a refresh and nobody cares, it's useState; if it does matter, it's a route or a query parameter.

Tip: nest the map the way you'd nest the components. If, when you sketch the screen, the frame contains the section and the section contains the tab, the map should have that same shape. When the map and the design diverge, remounts and duplication start creeping in.

Tip: use handle for everything declarative about the route. Crumbs, document title, menu icon, permission level. It's a free-form field, and it turns the map into the single source of truth about navigation.

Exercises

Exercise 1: the nested bookings section

Reorganise the bookings branch so that /reservas and /reservas/nueva share a common frame, BookingsFrame, that shows a "Bookings" heading and a small menu with two relative links ("My bookings" and "New booking") marking the active one. Requirements:

  • No URL should change: they stay /reservas and /reservas/nueva.
  • BookingsFrame shouldn't remount when moving from one to the other.
  • The "My bookings" link shouldn't show as active while on /reservas/nueva.

Exercise 2: document title from the map

Using handle and useMatches, write a hook useRouteTitle that sets document.title to a text made up of the deepest route's breadcrumb and the app's name, for example North Park · CicloUrbano. It must update on every navigation and fall back to CicloUrbano when no route declares a crumb.

Exercise 3: containing a failure in one branch

The incidents tab has a bug: if a bike has no status, bike.status.toUpperCase() throws. Configure the map so that failure doesn't take down the station detail screen — the title, the docks and the tabs must stay visible — only the tab's content. Explain which routes need errorElement and why the root route's isn't enough.

Solutions

Solution 1

// src/routes.jsx — fragment of the bookings branch
{
  path: 'reservas',
  element: <BookingsFrame />,          // ← now it does have an element
  handle: { crumb: 'Bookings' },
  children: [
    { index: true, element: <BookingsPage /> },
    { path: 'nueva', element: <NewBookingPage />, handle: { crumb: 'New booking' } }
  ]
}
// src/components/BookingsFrame.jsx
import { NavLink, Outlet } from 'react-router';
import { useBookings } from '../contexts/BookingsContext.jsx';
import styles from './BookingsFrame.module.css';

function BookingsFrame() {
  const { state } = useBookings();

  const linkClasses = ({ isActive }) =>
    isActive ? `${styles.link} ${styles.active}` : styles.link;

  return (
    <section>
      <h2>Bookings</h2>
      <p>{state.bookings.length} bookings on record</p>

      <nav aria-label="Bookings sections">
        <NavLink to="." end className={linkClasses}>
          My bookings
        </NavLink>
        <NavLink to="nueva" className={linkClasses}>
          New booking
        </NavLink>
      </nav>

      <Outlet />
    </section>
  );
}

export default BookingsFrame;

The three keys: the children keep index: true and path: 'nueva', so the URLs don't change; BookingsFrame is the parent's element and therefore persists across navigation between children, with only the <Outlet /> changing; and the end on to="." stops "My bookings" from showing as active on /reservas/nueva, because without it its destination would be a prefix of its sibling's.

Solution 2

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

const APP_NAME = 'CicloUrbano';

/**
 * Syncs document.title with the crumb of the deepest active route.
 * No parameters. Returns nothing.
 */
export function useRouteTitle() {
  const matches = useMatches();

  const withCrumb = matches.filter((m) => Boolean(m.handle?.crumb));
  const last = withCrumb.at(-1);

  const text =
    typeof last?.handle.crumb === 'function'
      ? last.handle.crumb(last.params)
      : last?.handle.crumb;

  useEffect(() => {
    // Synchronization with an external system (the document): the textbook useEffect case (05-02)
    document.title = text ? `${text} · ${APP_NAME}` : APP_NAME;
  }, [text]);
}

It's called once, from Layout, and it works on every screen:

function Layout() {
  useRouteTitle();
  // …
}

Two comments. First, the effect's dependency is text, a string, not the matches array: the latter is a new object on every render and would make the effect run on every single one, the dependency problem from 05-02. Second, this is exactly the definition of an effect we settled on in that lesson: synchronising React with an external system, in this case the document's title.

Solution 3

An errorElement is needed on the tab routes, not only on the root:

{
  path: ':estacionId',
  element: <StationDetailPage />,
  children: [
    { index: true, element: <FleetTab />, errorElement: <TabErrorPage /> },
    { path: 'incidencias', element: <IncidentsTab />, errorElement: <TabErrorPage /> }
  ]
}
// src/pages/TabErrorPage.jsx
import { useRouteError } from 'react-router';
import Notice from '../components/Notice.jsx';
import { reportError } from '../utils/monitoring.js';

function TabErrorPage() {
  const error = useRouteError();
  reportError(error, 'station detail tab');

  return (
    <Notice tone="error" title="We couldn't show this tab">
      <p>The station's data is still available on the other tab.</p>
    </Notice>
  );
}

export default TabErrorPage;

Why the root's errorElement isn't enough: React Router looks for the errorElement of the route that failed and, if it doesn't have one, walks up its ancestors until it finds one, replacing that route's element. If the nearest one is the root's, what gets replaced is the entire <Layout />: the header, breadcrumbs, footer, station title and tabs all disappear, and the user is left staring at a full error screen over a failure confined to one box. By putting the errorElement on the tab itself, the replacement happens exactly where StationDetailPage's <Outlet /> was: the rest of the screen stays intact and the user can switch to the other tab.

It's the same granularity logic from 04-05 with ErrorBoundarys around the catalogue and the form, now expressed in the route map instead of in the JSX. And it comes with a bonus: navigating to another route clears the error state on its own.

Conclusion

Nested routes are UI composition expressed through the URL: a parent route declares children and opens a slot with <Outlet />, and the child that matches the URL renders there. You've turned Layout into CicloUrbano's root route, and the gain is concrete and measurable: Header and Footer stop unmounting and remounting on every navigation, their effects don't repeat, their internal state persists, and the providers that live there — BookingsProvider and NoticesProvider — survive screen changes, which is what will let you create a booking on one screen and see it on another. You know that index: true marks a section's default screen, how it differs from path: '', and that a pathless route groups several screens under one shared frame without adding any segment to the URL, a pattern 06-05 will make its main tool.

You've built the station detail page with nested tabs: flota as the index route and incidencias as its sibling, with relative NavLinks — to="." with end, and to="incidencias" — that mark the active tab without interpolating the identifier, so each tab has its own shareable address and the back button toggles between them. useOutletContext has let you compute the station and its fleet once in the parent and pass them down to the tabs, reserving React context for what's genuinely ambient. useMatches together with the free-form handle field has turned the route map into the source of both the breadcrumbs and the document title, the clearest example yet of why, in data mode, it pays for the map to be data rather than markup. And errorElement with useRouteError gives you failure containment per branch, complementing 04-05's ErrorBoundary: that one is the global safety net and the only one that exists outside the router; this one also catches loader/action errors, tells apart Responses thrown on purpose with isRouteErrorResponse, and — no small advantage — clears itself on navigation.

Every navigation you've written so far starts with a click on a link. But there are situations where the application must navigate on its own: when the user confirms the BookingForm on /reservas/nueva, the right thing to do is create the booking, dispatch it to the bookingsReducer, and take them to /reservas without the form staying in history; when someone tries to leave a half-filled form, it's worth stopping them and asking; and while a navigation is in progress, it's worth telling them so. The next lesson is Programmatic Navigation.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved