106: React Router — Route Structure, URL State, and Navigation
Learning objectives
You will learn to:
- understand client-side routing as URL-to-UI state;
- configure nested routes with current React Router APIs;
- use layouts and outlets;
- read path params and search params;
- model shareable state in the URL;
- navigate declaratively and imperatively;
- distinguish Declarative, Data, and Framework modes;
- avoid storing router objects inside React state.
Why routing is state architecture
A URL can own durable navigation state:
/tasks /tasks/42 /tasks?status=open&page=3 /settings/profile
The URL provides:
- browser history;
- bookmarking;
- deep links;
- reload persistence;
- shareability.
Do not duplicate URL-owned state into a separate global store without a reason.
Current React Router setup
For a browser app:
npm install react-router
A current Data Router setup:
import {
createBrowserRouter,
} from 'react-router';
import {
RouterProvider,
} from 'react-router/dom';
import {
createRoot,
} from 'react-dom/client';
const router =
createBrowserRouter([
{
path: '/',
Component: RootLayout,
children: [
{
index: true,
Component: HomePage,
},
{
path: 'tasks',
Component: TaskListPage,
},
{
path: 'tasks/:taskId',
Component:
TaskDetailsPage,
},
],
},
]);
createRoot(
document.getElementById('root'),
).render(
<RouterProvider
router={router}
/>,
);
Create the router once outside the React render tree.
Do not:
function App() {
const [router] = useState(
() => createBrowserRouter(...),
);
}
unless you have an unusual architecture requiring it.
Layout routes and Outlet
import {
NavLink,
Outlet,
} from 'react-router';
function RootLayout() {
return (
<>
<header>
<nav>
<NavLink to="/">
Home
</NavLink>
<NavLink to="/tasks">
Tasks
</NavLink>
</nav>
</header>
<main>
<Outlet />
</main>
</>
);
}
Nested child routes render at <Outlet />.
This gives route structure a direct relationship to page structure.
Params
Route:
/tasks/:taskId
Read:
import {
useParams,
} from 'react-router';
function TaskDetailsPage() {
const { taskId } =
useParams();
return (
<h1>
Task {taskId}
</h1>
);
}
Params are strings.
Validate/parse them before using them in domain logic.
Search params
A task filter belongs in the URL if users should be able to share or reload it.
import {
useSearchParams,
} from 'react-router';
function TaskFilters() {
const [
searchParams,
setSearchParams,
] = useSearchParams();
const status =
searchParams.get('status')
?? 'all';
function changeStatus(next) {
setSearchParams((current) => {
const params =
new URLSearchParams(
current,
);
if (next === 'all') {
params.delete('status');
} else {
params.set(
'status',
next,
);
}
params.delete('page');
return params;
});
}
return (
<select
value={status}
onChange={(event) =>
changeStatus(
event.target.value,
)
}
>
<option value="all">
All
</option>
<option value="open">
Open
</option>
<option value="done">
Done
</option>
</select>
);
}
This integrates with history and deep linking.
Declarative navigation
Prefer links for navigation:
<Link to={`/tasks/${task.id}`}>
{task.title}
</Link>
A link is semantically navigation and supports browser behaviors such as opening in a new tab.
Do not replace all links with:
<button onClick={() => navigate(...)}>
Use imperative navigation when navigation follows a workflow, such as after a successful save.
useNavigate
const navigate = useNavigate();
async function save() {
const task = await createTask();
navigate(
`/tasks/${task.id}`,
{ replace: true },
);
}
Use replace when the previous route should not remain as a meaningful Back destination.
Relative routes
Nested routes can use relative navigation:
<Link to="edit">
Edit
</Link>
inside /tasks/:taskId can resolve to the nested edit route.
Understand the route hierarchy before using many ../ segments.
Index routes
{
path: 'settings',
Component: SettingsLayout,
children: [
{
index: true,
Component:
SettingsOverview,
},
{
path: 'profile',
Component:
ProfileSettings,
},
],
}
An index route acts like the default child at the parent URL.
Not-found routes
{
path: '*',
Component: NotFoundPage,
}
A 404 screen should:
- identify the missing route;
- preserve global navigation;
- provide a useful next step.
In server-rendered apps, correct HTTP status handling is also required.
Route organization
Do not put the entire route tree and all page implementations in one file.
Example:
src/ ├─ app/ │ └─ router.jsx ├─ routes/ │ ├─ RootLayout.jsx │ ├─ HomePage.jsx │ └─ tasks/ │ ├─ TaskListPage.jsx │ └─ TaskDetailsPage.jsx
The exact folders are flexible; clear ownership matters.
Router modes
Current React Router documents three modes:
- Declarative — components such as
BrowserRouter,Routes,Route; - Data —
createBrowserRouter, loaders, actions, fetchers; - Framework — Vite plugin, route modules, SSR/static features and more.
This curriculum uses Data Mode first because it exposes modern loader/action/error concepts without requiring the full framework runtime.
Common mistakes
Putting all state in URL
Not every temporary state should be shareable.
A one-character form draft does not automatically belong in the URL.
Putting URL state in Redux
If Back/Forward/bookmarks should control the value, let the router own it.
Navigating with button semantics
Use links for navigation and buttons for actions.
Recreating router during renders
The router is application infrastructure and should have stable identity.
Exercises
- Build
/tasks,/tasks/:taskId, and/settings/profile. - Add a root layout with an Outlet.
- Move status and page filters into search params.
- Add active navigation using
NavLink. - Add a not-found route.
- Explain when a value belongs in URL state versus component state.
Exit questions
- Why is the URL a state owner?
- What does
<Outlet>do? - What is an index route?
- When should you use a Link instead of navigate?
- Why should a Data Router be created outside the React tree?
- What is the difference between Declarative, Data, and Framework modes?
Official references
- https://reactrouter.com/start/modes
- https://reactrouter.com/start/data/installation
- https://reactrouter.com/start/data/routing
- https://reactrouter.com/api/data-routers/createBrowserRouter
Deep dive: route design should mirror product information architecture
A route tree is not just technical configuration.
Example:
/ ├─ tasks │ ├─ index │ ├─ :taskId │ │ └─ edit │ └─ new └─ settings ├─ profile └─ notifications
This tells you:
- URL structure;
- layout nesting;
- data boundaries;
- error boundaries;
- navigation hierarchy.
Avoid a flat list of unrelated routes when pages share meaningful layout/data context.
Route object example
const router = createBrowserRouter([
{
path: '/',
Component: RootLayout,
children: [
{
index: true,
Component: HomePage,
},
{
path: 'tasks',
Component: TasksLayout,
children: [
{
index: true,
Component: TaskListPage,
},
{
path: 'new',
Component: NewTaskPage,
},
{
path: ':taskId',
Component: TaskDetailsPage,
},
{
path: ':taskId/edit',
Component: EditTaskPage,
},
],
},
],
},
]);
Route params are untrusted strings
const { taskId } = useParams();
taskId can be:
"123" "abc" "../../../" very long string
Do not assume URL param validity.
Client can validate for UX.
Server must validate and authorize before accessing records.
Search params as durable UI state
Pagination/filter:
/tasks?status=open&page=3&sort=due
Benefits:
- shareable;
- reload-safe;
- back/forward;
- linkable;
- testable.
Normalize invalid values:
const status = ['all', 'open', 'done'].includes(rawStatus)
? rawStatus
: 'all';
Decide whether to:
- silently normalize;
- redirect to canonical URL;
- display error.
Multi-value params
/tasks?tag=frontend&tag=urgent
Read:
const tags = searchParams.getAll('tag');
Do not force every filter into comma-delimited custom parsing if URLSearchParams already models repeated keys cleanly.
Navigation state
Router navigation can carry transient state:
navigate('/tasks', {
state: {
notice: 'Task created',
},
});
This is not durable/bookmarkable URL state.
Use it for truly transient navigation context, not critical entity identity.
A page refresh may lose or reinterpret it.
Relative links and nested routes
Within:
/tasks/:taskId
a relative link:
<Link to="edit">Edit</Link>
is often more maintainable than reconstructing absolute URLs everywhere.
But overuse of ../../.. indicates route structure is becoming unclear.
Active navigation
NavLink can provide active/pending state.
<NavLink
to="/tasks"
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
Tasks
</NavLink>
Use semantic link state and aria-current support rather than manually storing "active menu item" in React state.
The URL already owns it.
Scroll and focus behavior
SPA navigation does not automatically recreate every browser full-page behavior.
Consider:
- scroll restoration;
- focus after route change;
- document title;
- announcement of new page context.
Router/framework tooling can help, but accessibility requires deliberate testing.
Route protection patterns
Client loader:
async function accountLoader() {
const user = await getCurrentUser();
if (!user) {
throw redirect('/login');
}
return { user };
}
This improves UX.
But API:
DELETE /api/tasks/42
must still independently authorize.
A user can skip the router entirely.
Route code splitting
Large route modules can be lazy-loaded.
The goal is to keep rare page code out of initial bundle.
Measure chunks.
Do not split a 2 KB route into many extra requests without evidence.
Route configuration location
Keep routing infrastructure stable and testable.
Avoid scattering string paths:
navigate('/tasks/' + id + '/edit')
across dozens of components without conventions.
You may centralize URL builders:
const taskRoutes = {
details(id) {
return `/tasks/${encodeURIComponent(id)}`;
},
edit(id) {
return `/tasks/${encodeURIComponent(id)}/edit`;
},
};
This is especially helpful when path shapes change.
BrowserRouter versus Data Router
Declarative BrowserRouter is fine for simpler client navigation.
Data Router is useful when route definitions should own:
- loaders;
- actions;
- fetchers;
- error boundaries;
- pending navigation.
This course moves to Data Router because later server/data concepts depend on those responsibilities.
Failure clinic
Duplicate filter ownership
URL says:
status=open
React global store says:
status=done
Which is truth?
Pick one owner.
Button used for navigation
Breaks expected link behaviors.
Auth-only hidden page
API remains exposed.
Query param parsing without defaults
Number(searchParams.get('page')) can become 0, NaN, negative, or huge. Validate.
Exercises
- Draw a route tree before writing configuration.
- Build nested task routes with layout + Outlet.
- Model filter/page in search params.
- Normalize invalid
pageandstatus. - Add active navigation.
- Add a client auth redirect and separately describe server authorization.
- Test deep link by loading a nested route directly.
Mastery check
Explain:
- URL as state owner;
- path params versus search params;
- route nesting;
- relative links;
- client route protection versus server authorization;
- Data Router motivation.
Production case study: search, pagination, and modal state in the URL
Suppose the product needs:
/tasks?status=open&q=invoice&page=2&task=t42
Meaning:
- open tasks;
- search invoice;
- page 2;
- details panel for task t42.
This can make the entire workspace deep-linkable.
Canonical parsing
function parseTaskSearch(searchParams) {
const rawPage = Number(searchParams.get('page') ?? '1');
return {
status: ['all', 'open', 'done'].includes(searchParams.get('status'))
? searchParams.get('status')
: 'all',
query: searchParams.get('q') ?? '',
page: Number.isInteger(rawPage) && rawPage > 0
? rawPage
: 1,
taskId: searchParams.get('task'),
};
}
Update one concern without deleting others
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set('status', status);
next.delete('page');
return next;
});
Do not do:
setSearchParams({ status });
if that unintentionally deletes search text and selected task.
Should modal state live in URL?
If details panel should be:
- shareable;
- Back-button aware;
- refresh-safe;
yes, URL may be appropriate.
If modal is a transient "Are you sure?" confirmation, local state is usually better.
State ownership is driven by user semantics, not UI shape.
Additional depth: navigation blockers, scroll restoration, and route UX
Unsaved changes
An edit route with dirty form may need a navigation blocker.
Do not indiscriminately block all navigation. Define:
What counts as dirty? Does autosave remove need? Should browser refresh warn? Should internal navigation show custom dialog? What if save is pending?
React Router exposes APIs for navigation blocking in appropriate modes. Treat them as UX safety, not data persistence.
A better solution for long forms may be server-side draft autosave so navigation does not threaten work.
Scroll restoration
SPA navigation can preserve unexpected scroll position.
Router/framework tooling can restore scroll based on navigation history.
Test:
list scrolled to row 80 open details Back
Should user return to previous list scroll? Usually yes.
Do not call:
window.scrollTo(0, 0)
on every route change without thinking about Back navigation.
Route focus
After meaningful navigation, keyboard/screen-reader users need context.
Common strategy:
- update document title;
- move focus to main heading or main region when appropriate;
- avoid stealing focus during minor search-param updates.
Changing:
?page=2
may not need same focus behavior as changing from:
/settings → /tasks
Canonical URLs
If:
?page=0 ?page=-4 ?page=abc
all mean page 1, consider redirecting to canonical URL:
/tasks?page=1
instead of carrying invalid state forever.
Canonicalization improves:
- sharing;
- analytics;
- caching;
- debugging.
Route-level metadata
A route should own title/meta where framework supports it.
Avoid one global Effect that manually inspects location strings and sets document.title through a giant switch.
Router/framework metadata APIs produce clearer ownership, especially with SSR.
Navigation error handling
A broken lazy chunk or loader should not trap user in blank screen.
Keep app shell/navigation available when possible and offer reload/retry.
This connects routing directly with Suspense/Error Boundary design rather than treating them as separate subjects.
