Module: React and Ecosystem
React and Ecosystem·106·7 MIN READ

106: React Router — Route Structure, URL State, and Navigation

TOPICS COVERED: 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:

text
/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:

bash
npm install react-router

A current Data Router setup:

jsx
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:

jsx
function App() {
  const [router] = useState(
    () => createBrowserRouter(...),
  );
}

unless you have an unusual architecture requiring it.

Layout routes and Outlet

jsx
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:

text
/tasks/:taskId

Read:

jsx
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.

jsx
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:

jsx
<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:

jsx
<button onClick={() => navigate(...)}>

Use imperative navigation when navigation follows a workflow, such as after a successful save.

useNavigate

jsx
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:

jsx
<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

jsx
{
  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

jsx
{
  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:

text
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;
  • DatacreateBrowserRouter, 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.

Use links for navigation and buttons for actions.

Recreating router during renders

The router is application infrastructure and should have stable identity.

Exercises

  1. Build /tasks, /tasks/:taskId, and /settings/profile.
  2. Add a root layout with an Outlet.
  3. Move status and page filters into search params.
  4. Add active navigation using NavLink.
  5. Add a not-found route.
  6. Explain when a value belongs in URL state versus component state.

Exit questions

  1. Why is the URL a state owner?
  2. What does <Outlet> do?
  3. What is an index route?
  4. When should you use a Link instead of navigate?
  5. Why should a Data Router be created outside the React tree?
  6. What is the difference between Declarative, Data, and Framework modes?

Official references


Deep dive: route design should mirror product information architecture

A route tree is not just technical configuration.

Example:

text
/
├─ 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

jsx
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

jsx
const { taskId } = useParams();

taskId can be:

text
"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:

text
/tasks?status=open&page=3&sort=due

Benefits:

  • shareable;
  • reload-safe;
  • back/forward;
  • linkable;
  • testable.

Normalize invalid values:

jsx
const status = ['all', 'open', 'done'].includes(rawStatus)
  ? rawStatus
  : 'all';

Decide whether to:

  • silently normalize;
  • redirect to canonical URL;
  • display error.

Multi-value params

text
/tasks?tag=frontend&tag=urgent

Read:

jsx
const tags = searchParams.getAll('tag');

Do not force every filter into comma-delimited custom parsing if URLSearchParams already models repeated keys cleanly.

Router navigation can carry transient state:

jsx
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.

Within:

text
/tasks/:taskId

a relative link:

jsx
<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.

jsx
<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:

jsx
async function accountLoader() {
  const user = await getCurrentUser();

  if (!user) {
    throw redirect('/login');
  }

  return { user };
}

This improves UX.

But API:

text
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:

jsx
navigate('/tasks/' + id + '/edit')

across dozens of components without conventions.

You may centralize URL builders:

jsx
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:

text
status=open

React global store says:

text
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

  1. Draw a route tree before writing configuration.
  2. Build nested task routes with layout + Outlet.
  3. Model filter/page in search params.
  4. Normalize invalid page and status.
  5. Add active navigation.
  6. Add a client auth redirect and separately describe server authorization.
  7. 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:

text
/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

jsx
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

jsx
setSearchParams((current) => {
  const next = new URLSearchParams(current);

  next.set('status', status);
  next.delete('page');

  return next;
});

Do not do:

jsx
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:

text
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:

text
list scrolled to row 80
open details
Back

Should user return to previous list scroll? Usually yes.

Do not call:

jsx
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:

text
?page=2

may not need same focus behavior as changing from:

text
/settings
→ /tasks

Canonical URLs

If:

text
?page=0
?page=-4
?page=abc

all mean page 1, consider redirecting to canonical URL:

text
/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.

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.