15.
Routing the Learning Tracker
Written by Eli Ganim
In the previous chapter, the Learning Tracker gained an app-wide preference. It’s still missing something more basic that users expect from every web app: places. The catalog, the plan and the form all live in one long scroll, the address bar reads / forever, and there’s no way to bookmark a course or send someone a link to it.
Client-side routing fixes that. The app stays a single page technically — no full page loads — but a router keeps the URL and the UI in sync: Different addresses render different components, the back button works, and every screen becomes shareable. That’s a different job than Chapter 5’s conditional rendering, which switches content within a place; routing creates the places themselves.
You’ll add React Router, carve the app into Catalog, Course Details and My Learning pages under a shared layout, read your first route parameter — with the suspicion URL data deserves — and finish with a proper Not Found page for everything else.
Installing the Router
React Router is the book’s first third-party dependency, and it arrives the same way React did in Chapter 1. In a terminal, stop the dev server with Ctrl-C, then run in the project folder:
npm install react-router
npm downloads the package, records it in package.json — you’ll see "react-router": "^8.2.0" or newer among the dependencies — and locks the exact version. Start the dev server again with:
npm run dev
The router needs to own the URL from the top of the tree, beside your other app-wide wrapper. In src/main.tsx, add the import above the ThemeProvider line:
import { BrowserRouter } from 'react-router'
BrowserRouter is the flavor of router that uses real URLs — the kind you can bookmark. Wrap the app with it, inside the theme provider:
<StrictMode>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</StrictMode>,
Save — no visible change, but every component below App can now ask about the URL. Here’s the destination structure, worth seeing before building it:
Read it top-down. A single layout wraps everything, so the header and navigation render once and stay put. Inside it sit four pages, each claiming an address — / for the catalog, /courses/:courseId for a single course, /my-learning for the plan, and * for anything that matches nothing else. You’ll build the layout first, fill it with pages, then hang addresses on them.
Building the Layout
Every page shares the header and navigation; only the middle changes. Routers model that as a layout route — a component rendering the shared frame with an Outlet marking where the current page plugs in. Create src/components/Layout.tsx:
import { NavLink, Outlet } from 'react-router'
import PageHeader from './PageHeader.tsx'
type LayoutProps = {
favoriteCount: number
}
function navLinkClass({ isActive }: { isActive: boolean }) {
return isActive ? 'nav-link active' : 'nav-link'
}
function Layout({ favoriteCount }: LayoutProps) {
return (
<>
<PageHeader favoriteCount={favoriteCount} />
<nav className="main-nav" aria-label="Main">
<NavLink to="/" className={navLinkClass} end>
Catalog
</NavLink>
<NavLink to="/my-learning" className={navLinkClass}>
My Learning
</NavLink>
</nav>
<main>
<Outlet />
</main>
</>
)
}
export default Layout
Three router pieces debut here. NavLink is a link that knows whether its destination matches the current URL — its className prop accepts a function receiving isActive, so the styling follows navigation automatically. The end prop on the Catalog link means “active only when the URL is exactly /” — without it, / prefixes every address and the link would never dim.
And Outlet is the socket: Whichever page the URL selects renders exactly there, inside the layout’s main. Remember Chapter 6’s landmark rule — this main is about to become the only main, moving here from App.
Save and run. Nothing on screen changes yet: Layout.tsx compiles, but nothing imports it until you declare the routes, so the app still renders through the old single-page App.
Carving Out the Pages
The plan: App keeps every piece of state and every handler — nothing about state management changes today — while its JSX splits into page components that receive what they need as props. Start with the simplest. Create a folder src/pages with NotFoundPage.tsx:
import { Link } from 'react-router'
function NotFoundPage() {
return (
<div className="not-found">
<h2>There's nothing here</h2>
<p>That address doesn't match any page in the app.</p>
<Link to="/">Back to the catalog</Link>
</div>
)
}
export default NotFoundPage
Link is the router’s replacement for <a>: It renders a real anchor — right-click and middle-click work — but intercepts plain clicks to swap pages without a browser reload. Chapter 6’s rule holds: This takes you somewhere, so it’s a link, not a button.
Next, src/pages/MyLearningPage.tsx — a thin adapter around the component you already have:
import MyLearning from '../components/MyLearning.tsx'
import type { Course } from '../types/course.ts'
import type {
LearningItem,
LearningStatus,
} from '../types/learning.ts'
type MyLearningPageProps = {
items: LearningItem[]
courses: Course[]
onSetStatus: (
courseId: string,
status: LearningStatus,
) => void
}
function MyLearningPage({
items,
courses,
onSetStatus,
}: MyLearningPageProps) {
return (
<MyLearning
items={items}
courses={courses}
onSetStatus={onSetStatus}
/>
)
}
export default MyLearningPage
Today it only forwards props; the wrapper earns its keep by giving the place a name that can grow — page titles, headers, whatever My Learning the destination needs later.
The catalog page is the big move. Create src/pages/CatalogPage.tsx and give it the entire catalog JSX that currently lives in App, fed by props — the full file is long but almost every line is a transplant:
import type { Ref } from 'react'
import Button from '../components/Button.tsx'
import SearchBar from '../components/SearchBar.tsx'
import CourseList from '../components/CourseList.tsx'
import AddCourseForm from '../components/AddCourseForm.tsx'
import type { CoursesRequest } from '../hooks/useCourses.ts'
import type { Course } from '../types/course.ts'
type CatalogPageProps = {
request: CoursesRequest
onRetry: () => void
courses: Course[]
visibleCourses: Course[]
emptyMessage: string
featuredCourse: Course | undefined
query: string
onQueryChange: (query: string) => void
searchInputRef: Ref<HTMLInputElement>
onFocusSearch: () => void
favoriteIds: string[]
planIds: string[]
onToggleFavorite: (courseId: string) => void
onAddToPlan: (courseId: string) => void
onRemoveCourse: (courseId: string) => void
onAddCourse: (course: Course, markAsFavorite: boolean) => void
}
Sixteen props — a lot, and honestly so: This page renders the app’s richest screen, and the type documents every dependency in one place. Then the component, moving the JSX in:
function CatalogPage({
request,
onRetry,
courses,
visibleCourses,
emptyMessage,
featuredCourse,
query,
onQueryChange,
searchInputRef,
onFocusSearch,
favoriteIds,
planIds,
onToggleFavorite,
onAddToPlan,
onRemoveCourse,
onAddCourse,
}: CatalogPageProps) {
The body is App’s current catalog JSX — the request gates, featured banner, search, count, list and always-present form — with two mechanical changes: Handler names swap for the on* prop names, and the outer <main> wrapper disappears, since it now belongs to the layout. Add it right after the signature:
return (
<>
{request.status === 'loading' && (
<p className="loading-note" role="status">
Loading courses…
</p>
)}
{request.status === 'error' && (
<div className="error-note" role="alert">
<p>{request.message}</p>
<Button label="Try again" onClick={onRetry} />
</div>
)}
{request.status === 'success' && (
<>
{featuredCourse !== undefined && (
<p className="featured-banner">
Featured: {featuredCourse.title}
</p>
)}
<SearchBar
query={query}
onQueryChange={onQueryChange}
inputRef={searchInputRef}
/>
<Button
label="Focus the search"
variant="ghost"
onClick={onFocusSearch}
/>
<p className="result-count">
Showing {visibleCourses.length} of{' '}
{courses.length} courses
</p>
<CourseList
courses={visibleCourses}
emptyMessage={emptyMessage}
favoriteIds={favoriteIds}
planIds={planIds}
onToggleFavorite={onToggleFavorite}
onAddToPlan={onAddToPlan}
onRemoveCourse={onRemoveCourse}
/>
</>
)}
<AddCourseForm onAdd={onAddCourse} />
</>
)
}
export default CatalogPage
Three request gates decide what shows: a loading note, an error panel with a Try again button wired to onRetry, or — on success — the featured banner, search bar, result count and course list. The add-course form sits outside the gates, always available. Every value and handler arrives as a prop; the page owns none of it.
Save and run. Still no visible change: CatalogPage, like Layout, compiles but nothing renders it yet. The wiring comes after one more page.
Reading the Route Parameter
Create src/pages/CourseDetailsPage.tsx, in pieces. First the frame:
import { Link, useParams } from 'react-router'
import Button from '../components/Button.tsx'
import LevelBadge from '../components/LevelBadge.tsx'
import type { CoursesRequest } from '../hooks/useCourses.ts'
import type { Course } from '../types/course.ts'
type CourseDetailsPageProps = {
request: CoursesRequest
onRetry: () => void
courses: Course[]
favoriteIds: string[]
planIds: string[]
onToggleFavorite: (courseId: string) => void
onAddToPlan: (courseId: string) => void
}
The page receives the request — because it must handle “courses haven’t arrived yet” and “the fetch failed” — plus onRetry and the data and callbacks any card would get. Now the component’s decision ladder:
function CourseDetailsPage({
request,
onRetry,
courses,
favoriteIds,
planIds,
onToggleFavorite,
onAddToPlan,
}: CourseDetailsPageProps) {
const { courseId } = useParams()
if (request.status === 'loading') {
return (
<p className="loading-note" role="status">
Loading courses…
</p>
)
}
if (request.status === 'error') {
return (
<div className="error-note" role="alert">
<p>{request.message}</p>
<Button label="Try again" onClick={onRetry} />
</div>
)
}
const course = courses.find(
(candidate) => candidate.id === courseId,
)
if (course === undefined) {
return (
<div className="not-found">
<h2>Course not found</h2>
<p>No course matches this address.</p>
<Link to="/">Back to the catalog</Link>
</div>
)
}
useParams returns the URL’s captured segments — here { courseId } — and every value is typed string | undefined, because URLs are just text the user can edit. The ladder’s order matters as much as its rungs. While courses are still loading, the page says loading; if the fetch failed, it says so and offers Try again — never “Course not found.” Only once the request has genuinely succeeded does find run, so a missing course means a truly unknown id, not a network hiccup. A direct visit to a details URL walks the whole ladder every time, since the fetch starts fresh.
Note: This page reads
requestbecauseAppstill owns the singleuseCoursescall, so every route shares one catalog load. Larger applications often flip that around: Each page or route starts its own data loading at its own boundary, so a details page fetches just its course. React Router and full-stack React frameworks provide route-level data APIs for exactly that — beyond this chapter, but the mental model they encode, a page is responsible for the data it shows, is what carries forward.
Only after all three guards does the page earn its content — add the happy path and close the component:
const isFavorite = favoriteIds.includes(course.id)
const inPlan = planIds.includes(course.id)
return (
<article className="course-details">
<p className="category">{course.category}</p>
<h2>{course.title}</h2>
<LevelBadge level={course.level} />
<p className="course-description">{course.description}</p>
<p className="duration">
{course.durationHours !== undefined
? course.durationHours + ' hours'
: 'Self-paced'}
</p>
{isFavorite && (
<p className="favorite-note">One of your favorites</p>
)}
<Button
label="Favorite"
onClick={() => onToggleFavorite(course.id)}
pressed={isFavorite}
/>
{inPlan ? (
<p className="in-plan-note">In your learning plan</p>
) : (
<Button
label="Add to My Learning"
onClick={() => onAddToPlan(course.id)}
variant="ghost"
/>
)}
<Link to="/" className="back-link">
Back to the catalog
</Link>
</article>
)
}
export default CourseDetailsPage
Past the guards, course is a plain Course and TypeScript lets everything through — the same narrow-then-use rhythm as ever, now applied to the URL. The favorite and plan controls work here exactly as on cards, because they call the same App handlers.
Save and run. Like the layout and the other pages, CourseDetailsPage compiles but nothing renders it yet — no visible change. Every page now exists; time to give them addresses.
Declaring the Routes
Time to wire addresses to pages. In src/App.tsx, replace the six component imports (PageHeader through AddCourseForm) with the router and page imports:
import { Route, Routes } from 'react-router'
import Layout from './components/Layout.tsx'
import CatalogPage from './pages/CatalogPage.tsx'
import CourseDetailsPage from './pages/CourseDetailsPage.tsx'
import MyLearningPage from './pages/MyLearningPage.tsx'
import NotFoundPage from './pages/NotFoundPage.tsx'
Every page these lines name now exists, so nothing here is red. Now replace App’s entire return with the route table:
return (
<Routes>
<Route
element={<Layout favoriteCount={favoriteIds.length} />}
>
<Route
index
element={
<CatalogPage
request={request}
onRetry={retry}
courses={courses}
visibleCourses={visibleCourses}
emptyMessage={emptyMessage}
featuredCourse={featuredCourse}
query={query}
onQueryChange={setQuery}
searchInputRef={searchInputRef}
onFocusSearch={handleFocusSearch}
favoriteIds={favoriteIds}
planIds={planIds}
onToggleFavorite={handleToggleFavorite}
onAddToPlan={handleAddToPlan}
onRemoveCourse={handleRemoveCourse}
onAddCourse={handleAddCourse}
/>
}
/>
<Route
path="courses/:courseId"
element={
<CourseDetailsPage
request={request}
onRetry={retry}
courses={courses}
favoriteIds={favoriteIds}
planIds={planIds}
onToggleFavorite={handleToggleFavorite}
onAddToPlan={handleAddToPlan}
/>
}
/>
<Route
path="my-learning"
element={
<MyLearningPage
items={learningPlan}
courses={courses}
onSetStatus={handleSetStatus}
/>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
)
Read the table like the diagram. The pathless outer Route is the layout route: It always matches, renders Layout, and its children fill the Outlet. index marks the catalog as the default child — what / shows. courses/:courseId contains the chapter’s most important character: a route parameter, where :courseId matches any single path segment and hands its text to the page. my-learning is a plain static path, and * catches every address nothing else claimed.
App keeps all its state, hooks and handlers above the return — the pages are its JSX, redistributed.
Save and run. The app compiles cleanly — no errors this time — and now moves on the router: The header sits up top and the catalog fills the space below. Click My Learning, and the plan takes over while the URL switches to /my-learning; the back button returns you. The nav is still unstyled plain text, though — you’ll fix that and capture the result next.
Linking the Cards
Details pages need front doors. In src/components/CourseCard.tsx, add the router import at the top:
import { Link } from 'react-router'
And turn the title into that door — replace the h2 line with:
<h2>
<Link
to={'/courses/' + course.id}
className="card-title-link"
>
{course.title}
</Link>
</h2>
Each card builds its own address from its id. Buttons still act, links still go — the card now has both, correctly assigned. Finally, style everything new at the bottom of src/App.css:
.main-nav {
max-width: 960px;
margin: 0 auto;
padding: 0 20px 16px;
display: flex;
gap: 10px;
}
.nav-link {
border-radius: 8px;
padding: 6px 14px;
font-weight: 600;
text-decoration: none;
color: var(--text);
}
.nav-link.active {
background: #24467c;
color: #ffffff;
}
.card-title-link {
color: inherit;
}
.course-details {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
max-width: 560px;
}
.course-details h2 {
margin: 0;
font-size: 1.5rem;
}
.course-details .button {
margin-top: 0;
}
.back-link,
.not-found a {
font-weight: 600;
color: #24467c;
}
:root[data-theme='dark'] .back-link,
:root[data-theme='dark'] .not-found a {
color: #7ea6e8;
}
.not-found {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
max-width: 560px;
}
.not-found h2 {
margin: 0 0 8px;
}
Nav pills with a solid active state, card titles that inherit their color, and surface panels for details and Not Found — all theme-aware through the variables, with dark companions for the links. Save and check the browser:
The catalog looks almost unchanged — and that’s the point. The same screen now lives at / as a proper page, with the Catalog and My Learning pills above it and the Catalog pill filled in to mark where you are. Everything below the nav is the JSX you moved into CatalogPage, rendering through the layout’s Outlet.
Touring the App’s Places
Exercise every road. Click a course title:
The URL reads /courses/react-basics, and reloading stays here — the fetch restarts, the loading note shows briefly, then details return. Click My Learning in the nav:
The plan gets a page of its own, the active pill follows, and the browser’s back button retraces your steps — the router is writing real history entries. Tab through the nav and links to confirm the keyboard story held: everything reachable, focus visible.
Now the unhappy roads. Type /courses/underwater-basket-weaving into the address bar:
The route matched — any segment satisfies :courseId — so the page itself delivers the verdict, with a road back. Then try /definitely-not-a-page:
The * route catches it. Two different failures, two honest answers — and no blank screens anywhere in the app.
Note: One member of the old single page changed its behavior subtly: The
/keyboard shortcut focuses the search box only when the catalog page is mounted — on other pages the ref isnull, and the guard from Chapter 12 quietly declines. A reasonable upgrade — navigating to the catalog first — waits for your own experiments.
Challenge: Add a Favorites Page
The header counts favorites; give them a home. Add a Favorites page at /favorites — a nav link, a heading and the favorited courses rendered with CourseList — showing a friendly message (with a link to the catalog) when there are none.
A few hints:
- A new page component, a new
Route, a newNavLink— the My Learning trio, replayed. - Derive the favorite courses by filtering
coursesagainstfavoriteIds; handle the loading and error states before filtering, just as the details page does. -
CourseListneeds its usual props; passing an emptyemptyMessageis fine since you guard the empty case yourself.
You’ll find a complete solution in the challenge folder of this chapter’s materials.
Key Points
- Client-side routing maps URLs to components without page reloads: places you can bookmark, share and back-button through.
-
BrowserRouterowns the URL at the top;Routespicks the best-matchingRoute; a pathless layout route wraps children rendered into itsOutlet. -
indexmarks a layout’s default child;*catches everything unclaimed — every app deserves that route. -
Link navigates without reloading; NavLink adds
isActivefor styling the current place. Buttons act, links go — still. - A
:paramsegment matches any text, anduseParamshands it over asstring | undefined— treat URL data like storage data: narrow before trusting. - Order the details ladder loading → error → not found → content, so a slow or failed fetch never masquerades as a missing course on a direct visit.
- Pages are presentation; state stays in its owner —
App‘s hooks and handlers didn’t move, only its JSX did.
Where to Go From Here?
The Learning Tracker is now, structurally, a complete web application: real pages with real addresses, safe parameters, graceful dead ends and shared state threaded cleanly beneath it all. Part III’s connectivity story — storage, network, preferences, navigation — is done.
Part IV is about earning confidence in all of it. Chapter 16 brings automated testing: rendering pages in tests, clicking buttons with simulated users, asserting on what people actually see — plus the debugging tools for when a test catches something. The Learning Tracker works; next you’ll prove it.