Build routes and URL state with React Router
Outcome
Section titled “Outcome”You will add React Router to Delivery Board in declarative mode. The application will provide a board route, a status-guide route, one dynamic detail route for every work-item ID, and a recovery route for unknown URLs. The selected board filter will move from local state into the URL, and client navigation will update the page heading, document title, browser history, and keyboard focus.
Why this matters
Section titled “Why this matters”A URL is part of the interface contract. A stable URL lets a user bookmark a view, share it, reload it, and use browser Back and Forward controls. When important view state exists only inside one component, those browser capabilities cannot reproduce the view.
Routing also creates new boundaries. The client router can select a React view after the application loads, but the server still receives a direct URL request first. A usable single-page application must handle both boundaries deliberately.
What you will practice
- Explain the responsibilities of BrowserRouter, Routes, Route, Link, NavLink, useParams, useSearchParams, and useLocation.
- Choose declarative routing for the current client without adopting unrelated data-router or framework features.
- Connect static paths, one dynamic path segment, and a catch-all route to focused page components.
- Use links instead of click handlers for navigation and expose the current primary route.
- Read an optional route parameter without a type assertion and show a usable missing-record result.
- Replace local filter state with validated URL search state that survives reload and browser history navigation.
- Keep persistent work-item state above the route switch while keeping route-specific feedback inside the board page.
- Update the document title and move focus to the new page heading after client-side path navigation.
- Distinguish a client catch-all view from the server rewrite required for direct application routes.
What is new and what is reused
Section titled “What is new and what is reused”- New: React Router declarative mode,
BrowserRouter,Routes,Route,Link,NavLink, active navigation, static routes, dynamic segments, route parameters, catch-all routes, search parameters,useSearchParams, browser history entries,useLocation, route-change effects, page-heading focus, document-title synchronization, and single-page-application fallback requirements. - Reused: The
feature/react-componentsbranch, typed item state, callback props, filter type guards, derived arrays, stable item IDs, native controls,useState,useRef, semantic page structure, responsive Sass, browser DevTools, keyboard checks, Vite production preview, and runtime-boundary validation.
Starting point
Before you start
- The completed Manage events and interface state in React lesson on a clean feature/react-components branch.
- App owns the current WorkItem array and currently renders the complete board interface.
- WorkItemCard reports typed status actions, and WorkItemList renders the current filtered collection.
- The selected filter currently uses local React state and returns to All statuses after a reload.
- The project passes typecheck, lint, and build before React Router is installed.
- No server data loader, form action, authentication rule, server-side rendering, or production deployment is required in this lesson.
- Current state
- Delivery Board has one client view at every browser path. Work-item details have no stable URL, the filter cannot be shared, and unknown paths have no application recovery view.
- First action
- Open the delivery-board repository, verify the clean state-lesson commit on feature/react-components, run the baseline checks, and install react-router from npm.
- First checkpoint
- BrowserRouter wraps App, the board and status-guide routes render through Routes, primary navigation uses links, and browser Back and Forward move between both views without a full document reload.
- Help trigger
- Use the nearest recovery note or ask for help if a router Hook reports that it is outside a router, every route renders at once, a link reloads the document, a parameter is undefined unexpectedly, the URL and select disagree, route navigation loses an understandable focus position, or a direct production-preview URL returns the wrong result.
Required result
Section titled “Required result”You have completed the lesson when:
- the work continues from the clean state-lesson commit on
feature/react-components; react-routeris a recorded runtime dependency inpackage.jsonandpackage-lock.json;- the current official declarative-mode import path,
react-router, is used consistently; BrowserRouterwrapsApponce inmain.tsx;Appkeeps work-item state above the route switch so in-memory status changes survive client navigation;- the application defines
/,/guide,/work-items/:itemId, and*route patterns; - one stable
maincontains only the best matching routed page, and every route result has one focusable pageh1; NavLinkexposes the current Board or Status guide destination, andLinkhandles app navigation without a full document reload;- every card links to its own
/work-items/<id>route; - the detail page reads
itemIdwithuseParams, renders the current matching record, and provides a back link; - an unknown work-item ID produces a specific Work item not found result without an assertion or crash;
- an unmatched path produces a Page not found result with a route back to the board;
- the board filter reads and writes the
statussearch parameter throughuseSearchParams; - an unsupported search value falls back to All statuses and shows visible recovery text;
- selecting a filter updates the URL, reload reproduces the filter, and Back and Forward restore prior filter selections;
- changing a filtered work item preserves the state lesson’s live feedback and focus recovery;
- path navigation updates
document.titleand moves focus to the new routeh1, while a filter-only URL change leaves focus on the filter control; - refreshing a valid detail URL and an unknown URL works in the Vite production preview;
- the lesson documents the server fallback needed before deployment with
BrowserRouter; - desktop, 320-pixel, 200%-zoom, keyboard, history, direct-load, and Console checks pass; and
- the final source passes typecheck, lint, and build before one focused lesson commit.
Resume the verified state branch
Section titled “Resume the verified state branch”Run the baseline commands from the Delivery Board project root:
git switch feature/react-componentsgit statusgit log -1 --onelinenpm cinpm run typechecknpm run lintnpm run buildConfirm that the latest commit is the completed state lesson and that the working tree is clean. If a baseline command fails, repair that exact state result before you install the router.
Choose one React Router mode
Section titled “Choose one React Router mode”Current React Router supports several modes. This project uses declarative mode.
| Mode | What it adds | Decision for this project |
|---|---|---|
| Declarative | URL matching, navigation, active links, route parameters, and search parameters inside the existing React client | Required |
| Data | Route objects plus loaders, actions, pending states, and other data APIs | Not required for the current local fixture |
| Framework | A React Router application framework with route modules, build conventions, and server or rendering strategies | Outside the established Vite client architecture |
Delivery Board already owns its Vite build, React entry point, typed state, and future API plan. Declarative mode adds the routing capabilities needed now without replacing those decisions.
A short PHP comparison
Section titled “A short PHP comparison”A PHP server route can match an HTTP path, read a path parameter, run server code, choose a template, and return an HTTP status. React Router declarative mode matches the browser URL after the single-page application loads and chooses client components.
Both approaches treat URLs and parameters as inputs. The important difference is the boundary: a direct request reaches the web server before React Router can run. A client * route can render a useful page, but it does not by itself configure the server’s HTTP 404 behavior or route fallback.
Install the current declarative package
Section titled “Install the current declarative package”Install React Router through the package manager:
npm install react-routernpm ls react-routerThe install command records the resolved version in package.json and package-lock.json. npm ls confirms which version the project actually uses.
This lesson follows the current official declarative documentation and imports browser APIs from react-router:
import { BrowserRouter } from "react-router";Older tutorials can import the same browser concepts from react-router-dom. Do not install a second compatibility package or mix import sources to follow an older example. Use the installed package, its lockfile, and documentation for that major version.
Run the source checks after installation:
npm run typechecknpm run lintnpm run buildIf npm reports a network, permission, engine, or peer-dependency error, record the complete first error. Do not add --force, remove the lockfile, or switch package names to suppress it.
Plan path and URL-state responsibilities
Section titled “Plan path and URL-state responsibilities”The finished client uses these URL contracts:
| URL pattern | Example | Page responsibility |
|---|---|---|
/ |
/ |
Board overview, filters, status actions, counts, and collection |
/?status=<filter> |
/?status=planned |
The same board with a shareable selected filter |
/guide |
/guide |
Explain the supported work statuses |
/work-items/:itemId |
/work-items/WI-002 |
Show one current work item selected by stable ID |
* |
/missing-page |
Recover from an unknown client route |
Use the URL only for state that benefits from browser navigation or sharing:
- Path: which page or record the user is viewing.
- Search parameter: which status subset the board shows.
- React state: current in-memory work-item records and transient action feedback.
Do not place complete work-item objects, last-action messages, or private data in the URL. URLs can appear in browser history, logs, screenshots, analytics, and copied messages.
Wrap the client in BrowserRouter
Section titled “Wrap the client in BrowserRouter”Replace src/main.tsx:
import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { BrowserRouter } from "react-router";import App from "./App";import "./styles.scss";
createRoot(document.getElementById("root")!).render( <StrictMode> <BrowserRouter> <App /> </BrowserRouter> </StrictMode>,);BrowserRouter reads and writes the browser’s History API and provides routing context to its descendants. App and every component that calls a React Router Hook must render inside this provider.
Keep one router provider. Do not wrap individual pages or cards in separate BrowserRouter instances. Multiple independent browser routers would compete for the same URL.
The generated non-null assertion remains only at the template-controlled #root boundary. Route parameters and search values must still be checked at runtime.
Confirm the provider boundary
Section titled “Confirm the provider boundary”Temporarily import and call useLocation inside App, then render location.pathname as test text. With BrowserRouter in main.tsx, the page should render the current path.
Move BrowserRouter below App temporarily and observe the router-context error. Restore the correct wrapper before continuing. The experiment proves that Hooks read the nearest router context rather than a global browser variable.
Remove the temporary path text after the provider check.
Create the routed page modules
Section titled “Create the routed page modules”Create src/pages and src/utils if they do not exist:
New-Item -ItemType Directory src/pages -ForceNew-Item -ItemType Directory src/utils -ForceThe finished source structure adds these responsibilities:
Directorysrc/
Directorycomponents/
- WorkItemCard.tsx — Render one item, status action, and detail link
- WorkItemList.tsx — Render a current collection or empty result
Directorydata/
- sample-work-items.ts — Provide initial trusted fixtures
Directorypages/
- BoardPage.tsx — Own board URL state, route-specific feedback, and filtered output
- NotFoundPage.tsx — Recover from an unmatched path
- StatusGuidePage.tsx — Explain the supported status cycle
- WorkItemDetailsPage.tsx — Read an item ID and render one current record
Directorytypes/
- work-item.ts — Define WorkStatus and WorkItem
Directoryutils/
- work-status.ts — Map supported statuses to visible labels
- App.tsx — Own current items, shared layout, primary navigation, route switch, title, and route focus
- main.tsx — Provide StrictMode and BrowserRouter
- styles.scss — Style shared layout, routed pages, navigation, and interaction
Route modules are normal React components. Their names describe page responsibilities; their file names do not create routes automatically in declarative mode. App will connect each path explicitly.
Share one status-label mapping
Section titled “Share one status-label mapping”The card and details page both need user-facing status labels. Create src/utils/work-status.ts:
import type { WorkStatus } from "../types/work-item";
export const statusLabels: Record<WorkStatus, string> = { planned: "Planned", active: "Active", done: "Done",};In WorkItemCard.tsx, import the mapping:
import { Link } from "react-router";import type { WorkItem, WorkStatus } from "../types/work-item";import { statusLabels } from "../utils/work-status";Remove the component-local statusLabels declaration. Keep nextStatusByStatus and actionLabels in the card because they control that component’s action interface.
Add a detail link after the status button inside .work-item-actions:
<div className="work-item-actions"> <button className="work-item-action" type="button" aria-label={`${actionLabel}: ${item.title}`} onClick={() => onStatusChange(item.id, nextStatus)} > {actionLabel} </button>
<Link className="work-item-link" to={`/work-items/${item.id}`} > View details </Link></div>The stable id now serves two related identity roles:
- React uses it as a sibling key in the list.
- React Router places it in the detail path.
The course fixture uses path-safe IDs such as WI-001. The API lesson must validate the external ID format before the client builds routes from received data.
Link renders an anchor with an href, preserves link semantics, and lets the client router handle same-application navigation. Do not replace it with a button and window.location assignment.
Move the board into its route page
Section titled “Move the board into its route page”Create src/pages/BoardPage.tsx:
import { useRef, useState, type ChangeEvent,} from "react";import { useSearchParams } from "react-router";import type { WorkItemStatusChangeHandler } from "../components/WorkItemCard";import { WorkItemList } from "../components/WorkItemList";import type { WorkItem, WorkStatus } from "../types/work-item";import { statusLabels } from "../utils/work-status";
type WorkFilter = "all" | WorkStatus;
type BoardPageProps = { items: readonly WorkItem[]; onStatusChange: WorkItemStatusChangeHandler;};
const filterLabels: Record<WorkFilter, string> = { all: "All statuses", planned: "Planned", active: "Active", done: "Done",};
function isWorkFilter(value: string): value is WorkFilter { return ( value === "all" || value === "planned" || value === "active" || value === "done" );}
function readWorkFilter(value: string | null): WorkFilter { return value !== null && isWorkFilter(value) ? value : "all";}
function formatWorkItemCount(count: number): string { return `${count} work ${count === 1 ? "item" : "items"}`;}
export function BoardPage({ items, onStatusChange,}: BoardPageProps) { const [searchParams, setSearchParams] = useSearchParams(); const [statusMessage, setStatusMessage] = useState(""); const statusMessageRef = useRef<HTMLParagraphElement>(null);
const requestedFilter = searchParams.get("status"); const filter = readWorkFilter(requestedFilter); const hasInvalidFilter = requestedFilter !== null && !isWorkFilter(requestedFilter);
const visibleItems = filter === "all" ? items : items.filter((item) => item.status === filter);
const doneCount = items.filter( (item) => item.status === "done", ).length;
const emptyMessage = items.length === 0 ? "No work items are available." : `No ${filterLabels[filter].toLowerCase()} work items match this filter.`;
function handleFilterChange(event: ChangeEvent<HTMLSelectElement>) { const nextFilter = event.target.value;
if (!isWorkFilter(nextFilter)) { setStatusMessage("The selected filter is not supported."); return; }
const nextSearchParams = new URLSearchParams(searchParams);
if (nextFilter === "all") { nextSearchParams.delete("status"); } else { nextSearchParams.set("status", nextFilter); }
setSearchParams(nextSearchParams); setStatusMessage(""); }
function handleBoardStatusChange( itemId: string, nextStatus: WorkStatus, ) { const changedItem = items.find((item) => item.id === itemId);
if (!changedItem) { setStatusMessage("The selected work item is no longer available."); statusMessageRef.current?.focus(); return; }
onStatusChange(itemId, nextStatus); setStatusMessage( `${changedItem.title} changed to ${statusLabels[nextStatus]}.`, );
if (filter !== "all" && filter !== nextStatus) { statusMessageRef.current?.focus(); } }
return ( <> <h1 className="route-heading" tabIndex={-1}> Board overview </h1>
<section className="board-controls" aria-labelledby="board-controls-heading" > <h2 id="board-controls-heading">Board controls</h2> <p> {formatWorkItemCount(visibleItems.length)} shown. {doneCount} of{" "} {items.length} done. </p>
<div className="filter-control"> <label htmlFor="status-filter">Show work items</label> <select id="status-filter" value={filter} onChange={handleFilterChange} > <option value="all">All statuses</option> <option value="planned">Planned</option> <option value="active">Active</option> <option value="done">Done</option> </select> </div>
{hasInvalidFilter && ( <p className="filter-warning" role="status"> The URL contains an unsupported status filter. Showing all statuses. </p> )}
<p ref={statusMessageRef} className="status-message" role="status" aria-atomic="true" tabIndex={-1} > {statusMessage} </p> </section>
<WorkItemList items={visibleItems} emptyMessage={emptyMessage} onStatusChange={handleBoardStatusChange} /> </> );}The current filter is no longer local React state. It is derived from the current URL on every render.
Treat search parameters as runtime input
Section titled “Treat search parameters as runtime input”searchParams.get("status") returns string | null:
nullmeans the parameter is absent;- a string can contain any decoded URL value, not only one of the four options.
readWorkFilter uses the existing type guard to return a valid WorkFilter. An unsupported value such as ?status=blocked becomes All statuses, and hasInvalidFilter makes the recovery visible.
Do not write:
const filter = searchParams.get("status") as WorkFilter;The assertion would allow null, misspelled values, manually edited URLs, and old shared links to enter the render model without validation.
Update one search key without discarding others
Section titled “Update one search key without discarding others”The change handler copies the current URLSearchParams, changes only status, then gives the result to React Router:
const nextSearchParams = new URLSearchParams(searchParams);nextSearchParams.set("status", nextFilter);setSearchParams(nextSearchParams);Selecting All statuses deletes the key so the canonical all-items URL remains / instead of /?status=all.
Calling setSearchParams causes navigation. The browser receives a history entry, the URL changes, and BoardPage renders from the next search parameters. There is no separate setFilter call to synchronize.
Keep route-specific feedback in the route
Section titled “Keep route-specific feedback in the route”Work-item data remains in App because the board and detail routes read it. The selected filter lives in the URL. The last board action message stays in BoardPage because it supports an interaction that exists only on that page.
Navigating away unmounts the board page and clears its transient message. Returning reconstructs the filter from the URL and reads the current shared items from App.
Create a dynamic work-item details page
Section titled “Create a dynamic work-item details page”Create src/pages/WorkItemDetailsPage.tsx:
import { Link, useParams } from "react-router";import type { WorkItem } from "../types/work-item";import { statusLabels } from "../utils/work-status";
type WorkItemDetailsPageProps = { items: readonly WorkItem[];};
export function WorkItemDetailsPage({ items,}: WorkItemDetailsPageProps) { const { itemId } = useParams(); const item = items.find((candidate) => candidate.id === itemId);
if (!item) { return ( <section className="route-page"> <h1 className="route-heading" tabIndex={-1}> Work item not found </h1> <p> No current work item matches{" "} <code>{itemId ?? "a missing identifier"}</code>. </p> <Link className="route-link" to="/"> Return to the board </Link> </section> ); }
return ( <section className="route-page"> <Link className="route-link" to="/"> Back to the board </Link>
<h1 className="route-heading" tabIndex={-1}> {item.title} </h1> <p>{item.description}</p>
<dl className="work-item-facts"> <div> <dt>Identifier</dt> <dd> <code>{item.id}</code> </dd> </div> <div> <dt>Status</dt> <dd>{statusLabels[item.status]}</dd> </div> </dl>
<h2>Labels</h2> <ul className="label-list"> {item.labels.map((label) => ( <li key={label}>{label}</li> ))} </ul> </section> );}The route pattern will use :itemId as a dynamic segment. useParams reads the matched value. Its type remains optional because the Hook API cannot prove that every call occurs under a route containing that parameter.
The code checks the collection result instead of asserting that an item exists. A valid route pattern can still contain an unknown ID, a removed record, an old bookmark, or a mistyped value.
The missing-item result is different from the catch-all route:
/work-items/WI-999matches the detail route, but no data record matches the parameter./unknown-sectionmatches no defined application route.
Both states need visible recovery, but their causes differ.
Create the status guide and catch-all pages
Section titled “Create the status guide and catch-all pages”Create src/pages/StatusGuidePage.tsx:
import { Link } from "react-router";
export function StatusGuidePage() { return ( <section className="route-page"> <h1 className="route-heading" tabIndex={-1}> Status guide </h1> <p> Delivery Board uses three statuses in one repeatable cycle. </p>
<dl className="status-guide"> <div> <dt>Planned</dt> <dd>The work is defined but has not started.</dd> </div> <div> <dt>Active</dt> <dd>The work is currently in progress.</dd> </div> <div> <dt>Done</dt> <dd>The current completion criteria are met.</dd> </div> </dl>
<Link className="route-link" to="/"> Return to the board </Link> </section> );}Create src/pages/NotFoundPage.tsx:
import { Link } from "react-router";
export function NotFoundPage() { return ( <section className="route-page"> <h1 className="route-heading" tabIndex={-1}> Page not found </h1> <p>The requested Delivery Board page does not exist.</p> <Link className="route-link" to="/"> Return to the board </Link> </section> );}The guide route gives the primary navigation a second meaningful destination. It documents the status language used by the interface. The catch-all page gives every unknown client path one clear recovery action.
Do not redirect unknown paths immediately. A visible not-found result tells the user what happened and gives them control over the next navigation.
Compose shared layout and route matching in App
Section titled “Compose shared layout and route matching in App”Replace src/App.tsx:
import { useEffect, useRef, useState,} from "react";import { Link, NavLink, Route, Routes, useLocation,} from "react-router";import type { WorkItemStatusChangeHandler } from "./components/WorkItemCard";import { sampleWorkItems } from "./data/sample-work-items";import { BoardPage } from "./pages/BoardPage";import { NotFoundPage } from "./pages/NotFoundPage";import { StatusGuidePage } from "./pages/StatusGuidePage";import { WorkItemDetailsPage } from "./pages/WorkItemDetailsPage";import type { WorkItem } from "./types/work-item";
type RoutedContentProps = { items: readonly WorkItem[]; onStatusChange: WorkItemStatusChangeHandler;};
function getNavLinkClassName({ isActive }: { isActive: boolean }) { return isActive ? "app-nav-link app-nav-link--active" : "app-nav-link";}
function RoutedContent({ items, onStatusChange,}: RoutedContentProps) { const location = useLocation(); const mainRef = useRef<HTMLElement>(null); const previousPathRef = useRef(location.pathname);
useEffect(() => { const pathChanged = previousPathRef.current !== location.pathname; previousPathRef.current = location.pathname; const heading = mainRef.current?.querySelector("h1");
if (!(heading instanceof HTMLHeadingElement)) { return; }
const headingText = heading.textContent?.trim() || "Delivery Board"; document.title = `${headingText} | Delivery Board`;
if (pathChanged) { heading.focus(); } }, [location.pathname]);
return ( <main id="main-content" ref={mainRef} className="route-content"> <Routes> <Route path="/" element={( <BoardPage items={items} onStatusChange={onStatusChange} /> )} /> <Route path="/guide" element={<StatusGuidePage />} /> <Route path="/work-items/:itemId" element={<WorkItemDetailsPage items={items} />} /> <Route path="*" element={<NotFoundPage />} /> </Routes> </main> );}
function App() { const [items, setItems] = useState<WorkItem[]>(sampleWorkItems);
const handleStatusChange: WorkItemStatusChangeHandler = ( itemId, nextStatus, ) => { setItems((currentItems) => currentItems.map((item) => item.id === itemId ? { ...item, status: nextStatus } : item, ), ); };
return ( <div className="app-shell"> <header className="product-header"> <p className="eyebrow">Module 3.1 project</p> <p className="app-name"> <Link to="/">Delivery Board</Link> </p> <p>Review and update the current work-item status.</p> </header>
<nav className="app-nav" aria-label="Primary"> <NavLink className={getNavLinkClassName} to="/" end > Board </NavLink> <NavLink className={getNavLinkClassName} to="/guide" > Status guide </NavLink> </nav>
<RoutedContent items={items} onStatusChange={handleStatusChange} /> </div> );}
export default App;Match one route branch
Section titled “Match one route branch”Routes examines its child route patterns against the current location and renders the best matching element.
<Route path="/work-items/:itemId" element={<WorkItemDetailsPage items={items} />}/>The literal segments are work-items. The leading colon marks itemId as a dynamic segment. React Router supplies its decoded value through useParams inside the matched details page.
The * route is last for readability, although ranked matching chooses the most specific route rather than stopping at the first child. It handles any path that does not match the three defined page patterns.
Keep shared state above Routes
Section titled “Keep shared state above Routes”App owns items, while RoutedContent switches page components. Navigating from the board to a details page does not unmount App, so current in-memory statuses remain available to both routes.
Test this boundary:
- Change
WI-001from Planned to Active. - Open its details link.
- Confirm that the details page shows Active.
- Return to the board.
- Confirm that the board still shows Active.
A full browser reload still restores the fixtures because no persistence layer exists.
Use links for navigation
Section titled “Use links for navigation”Linkis for a destination whose active state is not part of primary navigation, such as one work-item detail or a return route.NavLinkis for navigation that must expose the current destination. It appliesaria-current="page"when active and suppliesisActiveto the class callback.- A normal
<a>remains appropriate for a different website, download, email address, or protocol. - A
buttonis for an action that changes the current interface, not for navigating to a known URL.
The end prop on the Board NavLink prevents / from appearing active at every path that begins with /.
Move focus after path navigation
Section titled “Move focus after path navigation”Client-side navigation does not perform a full document load. The browser can keep focus on the link that activated navigation even after the route content changes.
RoutedContent uses useLocation to observe the current pathname and an effect to synchronize two browser APIs:
const previousPathRef = useRef(location.pathname);
useEffect(() => { const pathChanged = previousPathRef.current !== location.pathname; previousPathRef.current = location.pathname; const heading = mainRef.current?.querySelector("h1");
if (!(heading instanceof HTMLHeadingElement)) { return; }
const headingText = heading.textContent?.trim() || "Delivery Board"; document.title = `${headingText} | Delivery Board`;
if (pathChanged) { heading.focus(); }}, [location.pathname]);This effect is appropriate because it synchronizes React output with document.title and DOM focus. Every route page supplies one h1 with tabIndex={-1}, so the heading can receive programmatic focus without entering the normal Tab sequence.
previousPathRef separates an initial document load from a client-side path change. The initial route receives the correct title without moving browser focus. Moving to another client path updates the title and heading focus.
The effect depends on location.pathname, not the complete location. Changing ?status= updates the board view but does not move focus away from the filter select.
Checkpoint: Static routes navigate through one accessible client shell
- What now works
- BrowserRouter provides context, Board and Status guide render one at a time, NavLink exposes the current destination, Back and Forward work without a document reload, and each path change updates title and h1 focus.
- Files changed
package.json, package-lock.json, src/main.tsx, src/App.tsx, src/pages/BoardPage.tsx, src/pages/StatusGuidePage.tsx- What remains
- Verify dynamic records, URL filter state, unknown routes, direct requests, and production-hosting requirements.
- Next action
- Open each work-item detail link, test one missing ID and one unmatched path, then run the search-parameter sequence.
- If it does not work
- Confirm package import → BrowserRouter provider → Routes match → Route element → Link destination. Then inspect the first router-context, match, or Console error.
Style routed navigation and pages
Section titled “Style routed navigation and pages”Add the routed page to the surface selector:
.product-header,.board-controls,.route-page,.work-item { @include surface;
padding: $space-8;}Add these rules after the product-header styles:
.app-name { margin-block: 0; font-size: clamp(2rem, 6vw, 3rem); font-weight: 700; line-height: 1.1;}
.app-name a,.route-link,.work-item-link { color: #174d37; text-underline-offset: 0.2em;}
.app-name a:hover,.route-link:hover,.work-item-link:hover { text-decoration-thickness: 0.15em;}
.app-nav { display: flex; flex-wrap: wrap; gap: $space-4; margin-block-start: $space-6;}
.app-nav-link { display: inline-flex; min-height: 2.75rem; align-items: center; border: 1px solid #587064; border-radius: 0.4rem; padding: 0.5rem 0.75rem; color: #174d37; background: #ffffff; font-weight: 700; text-decoration: none;}
.app-nav-link:hover { background: #e6f2eb;}
.app-nav-link--active { border-color: #1c6243; color: #ffffff; background: #1c6243;}
.route-content { margin-block-start: $space-8;}
.route-heading:focus,.app-name a:focus-visible,.app-nav-link:focus-visible,.route-link:focus-visible,.work-item-link:focus-visible { outline: 0.2rem solid #1c6243; outline-offset: 0.2rem;}
.work-item-actions { display: flex; flex-wrap: wrap; align-items: center; gap: $space-4;}
.filter-warning { border-inline-start: 0.25rem solid #8b4a13; padding-inline-start: $space-4; color: #59300e; background: #fff4e8;}
.status-guide { display: grid; gap: $space-4;}
.status-guide div { border-inline-start: 0.25rem solid #1c6243; padding-inline-start: $space-4;}
.status-guide dt { font-weight: 700;}
.status-guide dd { margin: 0.25rem 0 0;}Add .app-name and .route-heading to the existing wrapping selectors. Add .route-page to the narrow surface padding selector:
@media (max-width: 30rem) { .app-shell { margin-block: $space-4; }
.product-header, .board-controls, .route-page, .work-item { padding: $space-4; }}The active navigation style uses text, contrast, and aria-current; color is not its only signal. Every new navigation target preserves a visible focus outline. The route heading shows focus after navigation, so the focus move is observable rather than silent.
If the routed layout does not work
Section titled “If the routed layout does not work”- If page headings have no visible focus, confirm
tabIndex={-1}, theroute-headingclass, and the:focusrule. - If both primary links look active, confirm that the Board
NavLinkusesend. - If a card action and detail link overlap, confirm that
.work-item-actionswraps and has a gap. - If the active link has insufficient contrast, confirm the white text and dark green background remain paired.
- If the warning relies only on orange, keep the complete visible explanation and border treatment.
- If any route page causes horizontal scrolling, inspect long IDs, code elements, links, and fixed widths before hiding overflow.
Test dynamic route parameters
Section titled “Test dynamic route parameters”Run the development server and test the three fixture links:
| Link | Expected URL | Expected heading |
|---|---|---|
WI-001 details |
/work-items/WI-001 |
Prepare the accessible navigation review |
WI-002 details |
/work-items/WI-002 |
Document the component responsibilities |
WI-003 details |
/work-items/WI-003 |
Verify the production build |
For each detail route, confirm the ID, current status, description, and labels match the selected record. Use the Back to the board link and the browser Back button as separate tests.
Then enter this URL manually:
/work-items/WI-999Expected result:
- the route renders Work item not found;
- the unknown ID appears as text inside
code; - no property access error appears in the Console; and
- Return to the board works.
Now enter an unmatched path:
/does-not-existExpected result:
- the route renders Page not found;
- the catch-all result does not claim that a specific work item is missing;
- the page title updates;
- the page contains one focusable route heading; and
- the recovery link returns to
/.
If a parameter does not match
Section titled “If a parameter does not match”- Confirm the route path contains the exact
:itemIdname. - Confirm
useParamsdestructures the sameitemIdspelling and capitalization. - Confirm card links use
/work-items/${item.id}. - Confirm fixture IDs and URLs use the same case.
- Keep the not-found branch. Do not add a non-null assertion after
find.
Test URL search state
Section titled “Test URL search state”Reload / before this sequence.
Change and reproduce the filter
Section titled “Change and reproduce the filter”- Select Planned.
- Confirm that the URL becomes
/?status=planned. - Confirm that one planned card appears.
- Copy the complete URL into a new tab.
- Confirm that the new tab opens the same filter result.
- Reload that tab and confirm the filter remains Planned.
- Select All statuses and confirm that the
statuskey is removed from the URL.
This behavior proves that the URL, not hidden local filter state, owns the selection.
Use browser history
Section titled “Use browser history”From /, select Planned, then Active, then Done. Use the browser Back button three times and Forward three times.
At every history position, the select value, visible card, shown count, and URL must agree. Do not add a popstate listener; React Router already connects search-parameter navigation to browser history.
Recover from an unsupported value
Section titled “Recover from an unsupported value”Enter:
/?status=blockedExpected result:
- the select shows All statuses;
- all three records appear;
- visible text explains that the URL filter is unsupported;
- the application does not crash; and
- selecting a supported filter replaces the invalid value.
Preserve filtered focus recovery
Section titled “Preserve filtered focus recovery”Reload /?status=planned, Tab to Start work, and press Enter.
The item becomes Active and no longer matches the URL filter. Confirm that:
- the URL remains
/?status=planned; - the planned result becomes empty;
- the filter-specific empty message appears;
- the live message names the updated item and Active status; and
- focus moves to the status message.
The URL describes the requested view. It does not change to follow an item that leaves that view.
Checkpoint: Paths and search values reproduce the current view
- What now works
- Every item ID opens a current detail page, unknown IDs and paths recover visibly, supported search filters survive copy, reload, Back, and Forward, invalid search input falls back safely, and filtered actions preserve feedback and focus.
- Files changed
src/pages/BoardPage.tsx, src/pages/WorkItemDetailsPage.tsx, src/pages/NotFoundPage.tsx, Browser address bar, Browser history, Browser Console- What remains
- Verify route accessibility, production direct loads, the host fallback boundary, final source checks, and the focused Git commit.
- Next action
- Run the route focus and title sequence, then build and open direct route URLs in the production preview.
- If it does not work
- Start at `/`, verify one static route, one dynamic route, one search value, and one catch-all path separately. Repair the first URL-to-view mismatch before combining tests.
Verify navigation focus and document titles
Section titled “Verify navigation focus and document titles”Use keyboard navigation for this sequence:
- Start at
/and confirm the title is Board overview | Delivery Board. - Tab to Status guide and press Enter.
- Confirm the title becomes Status guide | Delivery Board.
- Confirm focus is on the Status guide
h1. - Press Tab and confirm focus moves to the next route-page link rather than returning to a hidden or removed control.
- Return to the board and open one work-item detail link.
- Confirm the document title uses that work-item heading and focus is on the same visible
h1. - Use browser Back and confirm title and heading focus update for the previous path.
- Change only the board filter and confirm focus stays on the select rather than moving to Board overview.
Inspect the Elements and Accessibility panels:
- one
mainremains in the shared shell; - each matched route renders one
h1inside thatmain; - primary navigation has an accessible name;
- the current primary
NavLinkhasaria-current="page"; - route headings have
tabindex="-1"but do not enter the normal Tab sequence; and - links have meaningful accessible names and real
hrefvalues.
Client routing does not remove the browser’s normal link affordances. A user can copy a link, open it in a new tab, and inspect its destination.
Distinguish client recovery from server fallback
Section titled “Distinguish client recovery from server fallback”BrowserRouter uses clean paths such as /work-items/WI-001. Two different navigation paths must succeed:
Client navigation
Section titled “Client navigation”The browser already loaded index.html and the React application. Activating a Link updates history, and React Router renders the matching component.
Direct request or refresh
Section titled “Direct request or refresh”The browser requests /work-items/WI-001 from the web server. The server must return the application’s index.html for a valid client route. React then loads and matches the URL.
If the server searches only for a physical /work-items/WI-001 file, it can return a server 404 before React runs.
The Vite development server and production preview provide a suitable fallback for this lesson. A real deployment must configure an equivalent rewrite for application routes while still serving actual static files normally.
Do not switch to HashRouter only to avoid learning the host boundary. Hash routing can work on servers without rewrites, but it changes public URLs to include # and is a separate architecture decision.
Run production direct-load checks
Section titled “Run production direct-load checks”Run the complete source checks:
npm run typechecknpm run lintnpm run buildnpm run previewUse the exact preview URL reported by Vite. Test these direct addresses in new tabs, not only through client links:
//?status=done/guide/work-items/WI-001/work-items/WI-999/does-not-existFor each address, confirm:
- the preview returns the application rather than a server error;
- the correct route result appears after load;
- the page title matches the route heading;
- the route contains one focusable
h1; - reload keeps the same path and search value;
- the Console contains no router warning or application error; and
- returning through links and browser history works.
Also verify normal desktop width, 320 CSS pixels, 200% zoom, long unknown IDs, primary-nav wrapping, focus outlines, and no horizontal page scrollbar.
Stop the preview with Ctrl+C after the checks.
Diagnose the first routing boundary
Section titled “Diagnose the first routing boundary”| Symptom | Likely boundary | Focused check |
|---|---|---|
| Router Hook error | Provider | Confirm BrowserRouter wraps App once in main.tsx |
| Blank route output | Match or import | Read the first Console error, then compare Route path and element import |
| Full page reload on internal navigation | Navigation element | Use Link or NavLink rather than an anchor for same-app routes |
| Board link active everywhere | NavLink match | Add end to the root destination |
| Detail page always missing | Dynamic parameter | Compare :itemId, useParams, link path, and fixture ID |
| Unknown ID crashes | Data lookup | Keep the explicit if (!item) branch before property access |
| Select and URL disagree | Search ownership | Derive filter from searchParams and remove local filter state |
| Back does not restore a filter | Navigation update | Use setSearchParams instead of direct history or location mutation |
| Search value bypasses the union | Runtime narrowing | Restore isWorkFilter and remove assertions |
| Heading focus changes on filter selection | Effect dependency | Depend on pathname rather than the complete location |
| Heading never receives focus | Route contract | Confirm every result has one route-heading h1 with tabIndex -1 |
| Client links work but refresh fails on a host | Server boundary | Configure an SPA fallback to index.html for application routes |
Repair the earliest failing boundary. Adding another route cannot repair a missing provider, and changing the filter component cannot repair a server rewrite.
Inspect and commit the routing change
Section titled “Inspect and commit the routing change”After the production checks, inspect the project:
git status --shortgit diffThe expected change contains:
package.jsonandpackage-lock.jsonforreact-router;- changed
src/main.tsx,src/App.tsx,src/components/WorkItemCard.tsx, andsrc/styles.scss; - four new
src/pagesfiles; and - one new
src/utils/work-status.tsfile.
WorkItemList.tsx, the fixture, and the work-item type do not need a routing change. Generated output and installed package folders remain outside Git.
Stage the exact paths:
git add package.json package-lock.json src/main.tsx src/App.tsx src/styles.scssgit add src/components/WorkItemCard.tsx src/pages src/utils/work-status.tsgit diff --cachedgit commit -m "feat: add routed work-item views"git statusThe final status must be clean on feature/react-components. Keep the branch for the testing lesson and Project stage 2 review.
Self-check
Complete these checks against the required result.
- Confirm that routing started from the clean state-lesson commit and that react-router is recorded in both dependency files.
- Explain why this client uses declarative mode rather than adding data-router or framework responsibilities.
- Confirm that all router imports use react-router and that BrowserRouter wraps App once in main.tsx.
- Point to the four Route patterns and state which component each pattern renders.
- Confirm that Routes renders one best matching branch inside one stable main.
- Use Board and Status guide navigation with pointer, Enter, browser Back, and browser Forward without a full document reload.
- Confirm that the current primary NavLink exposes aria-current and that the root link is not active on every path.
- Open all three detail links and confirm that the URL parameter selects the current matching item.
- Change one status before opening its detail route and confirm that shared App state remains current across navigation.
- Open an unknown item ID and confirm a specific recovery result without an assertion or Console error.
- Open an unmatched path and confirm the catch-all page, page title, focusable h1, and recovery link.
- Point to requestedFilter, readWorkFilter, and isWorkFilter and explain how they handle string or null runtime input.
- Confirm that the selected filter is not duplicated in useState.
- Select every filter and confirm that the URL, select, visible cards, and derived counts agree.
- Copy and reload a filtered URL and confirm that it reproduces the same view.
- Use Back and Forward across three filter choices and confirm that the view follows history.
- Open an unsupported status value and confirm visible recovery and the All statuses fallback.
- Change the only card inside one status filter and confirm the URL remains stable while live feedback and focus recovery work.
- Confirm that path changes update document.title and h1 focus while search-only changes leave focus on the select.
- Explain why the client catch-all does not configure the server response or SPA fallback.
- Open every required URL directly in the production preview and confirm reload, content, title, focus, and Console results.
- Verify primary navigation and every route at 320 CSS pixels and 200 percent zoom without clipped content or page overflow.
- Run typecheck, lint, and build, inspect the exact diff, create the routing commit, and confirm a clean feature branch.
Explanation: the URL is one source of interface state
Section titled “Explanation: the URL is one source of interface state”The routed client now separates four state lifetimes:
- Module data supplies the initial trusted fixtures.
- App state keeps current work-item values while the client remains mounted.
- URL path and search state survive client navigation, copy, history, and reload.
- Route-local state keeps transient feedback only while the board page remains mounted.
The URL is not automatically the correct home for every value. Put a value there when the user benefits from identifying, sharing, or restoring that view. Keep large objects, secrets, transient messages, and authoritative server data elsewhere.
React Router reads the current location, selects one page component, and provides navigation and URL-value APIs. It does not validate work-item data, persist status changes, configure the production host, or create meaningful headings. Those remain application responsibilities.
Official references
Section titled “Official references”- Picking a Mode — declarative, data, and framework responsibility boundaries
- Declarative Installation — current package and
BrowserRoutersetup - Declarative Routing —
Routes,Route, static paths, and dynamic segments - Declarative Navigating —
Link,NavLink, and browser history navigation - URL Values — route parameters and search parameters
useSearchParams— reading and navigating with URL search stateNavLink— active-state and current-page behavior
Optional extensions
Section titled “Optional extensions”The required route contract is complete before these routes. Keep each selected extension separate from the required commit.
Next step or safe stopping point
Section titled “Next step or safe stopping point”The required result is safely paused when static, dynamic, search, missing-record, and catch-all routes work in the production preview; path changes manage title and heading focus; direct URLs reload; the host fallback boundary is documented; all checks pass; the routing commit exists; and git status is clean on feature/react-components.
The next lesson adds Vitest and React Testing Library. It will turn the component, state, routing, URL, focus, and recovery contracts into selected automated behavior checks without replacing the required browser verification.