Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

15. Routing the Learning Tracker
Written by Eli Ganim

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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:

The route tree: one layout wrapping four pages, each owning an address pattern.
The route tree: one layout wrapping four pages, each owning an address pattern.

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

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
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
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
}
function CatalogPage({
  request,
  onRetry,
  courses,
  visibleCourses,
  emptyMessage,
  featuredCourse,
  query,
  onQueryChange,
  searchInputRef,
  onFocusSearch,
  favoriteIds,
  planIds,
  onToggleFavorite,
  onAddToPlan,
  onRemoveCourse,
  onAddCourse,
}: CatalogPageProps) {
  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

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
}
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>
    )
  }
  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

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'
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>
)

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'
<h2>
  <Link
    to={'/courses/' + course.id}
    className="card-title-link"
  >
    {course.title}
  </Link>
</h2>
.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;
}
The catalog, now a page: navigation above, Catalog pill active.
Wcu wesiboy, zir a doqi: bulucifeuz uwuvu, Mahokax fotr uzzidu.

Touring the App’s Places

Exercise every road. Click a course title:

A course's own address — bookmark it, share it, reload it.
I pionha'f ild adbjetw — xuajgojw ih, wpiru is, tenuoh up.

My Learning as a destination, with its nav pill lit.
Wl Suephohm ah a sapjasajoow, honj eqm vor runq dup.

A valid route pattern, an unknown id — handled by the page, not the router.
E wevun jaawa tegxopd, uz eqxbatj iw — fobkxuz xj vgi huco, ruz jhe nuaqac.

The catch-all: anything unclaimed lands softly.
Sbu liwpr-uzm: eblqhoss uhjnaaliq saxyt wabsgy.

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.

Key Points

  • Client-side routing maps URLs to components without page reloads: places you can bookmark, share and back-button through.
  • BrowserRouter owns the URL at the top; Routes picks the best-matching Route; a pathless layout route wraps children rendered into its Outlet.
  • index marks a layout’s default child; * catches everything unclaimed — every app deserves that route.
  • Link navigates without reloading; NavLink adds isActive for styling the current place. Buttons act, links go — still.
  • A :param segment matches any text, and useParams hands it over as string | 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now