React Router Implementation

React Router v6.4+ turns routing into a data layer: a tree of route objects where each node can own its own data loader, mutation action, error boundary, and lazily-loaded component. If you are building a single-page application and need nested layouts, parallel data fetching before paint, and route-level code splitting that actually shrinks your initial bundle, this page walks through the exact configuration that gets you there — with the failure modes that bite teams in production.

← Back to Framework-Specific Routing Patterns

React Router data flow A URL is matched to a route object, its loader runs before paint, and the element renders reading the result with useLoaderData. React Router data flow URL /users/42 Match route route object tree Loader runs before render Render useLoaderData()
createBrowserRouter resolves a URL to a route, runs its loader before paint, then renders the element that reads the data via useLoaderData.

The Problem

A naive React Router setup renders a component, the component mounts, an effect fires, and only then does data fetching begin. Nest three of those layouts and you get a request waterfall: the shell mounts, fetches, renders the next layer, which mounts, fetches, and so on. Time to Interactive balloons, spinners cascade down the page, and every layout shift hurts your Core Web Vitals.

The second failure is bundle bloat. Import every route statically and the user downloads the admin panel, the settings screen, and the reporting dashboard just to view the home page. Without route-level splitting, the entry chunk grows linearly with the application.

The third is correctness during navigation: scroll position is lost, focus is dropped (a screen-reader and keyboard accessibility regression), in-flight requests are not cancelled when the user navigates away, and redirects flash unauthorised content before the guard runs. React Router v6.4+ solves all three when configured deliberately — but only the data router (createBrowserRouter) unlocks loaders, actions, and <Await>. The legacy <BrowserRouter> component tree does not. This is the most consequential decision you will make in the setup, so make it first. The router builds on the browser’s native History API for every client-side transition, so the same constraints around history.state and the popstate event apply here too.

Core API & Primitives

The data router is constructed from an array of RouteObject nodes. The members you will reach for most:

// react-router-dom v6.22 — TypeScript 5.x
import type { RouteObject, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router-dom';

// A route node: path or index, an element to render, optional data hooks,
// optional error boundary, and nested children that render into <Outlet />.
type Route = RouteObject & {
  path?: string;
  index?: boolean;
  element?: React.ReactNode;
  loader?: (args: LoaderFunctionArgs) => Promise<unknown> | unknown;
  action?: (args: ActionFunctionArgs) => Promise<unknown> | unknown;
  errorElement?: React.ReactNode;
  children?: RouteObject[];
};

// LoaderFunctionArgs hands you the matched params, the Request (with an
// AbortSignal), and the route context. Return data or throw a Response.
type LoaderArgs = {
  params: Record<string, string | undefined>;
  request: Request; // request.signal aborts on navigation away
};

The hooks that read this data inside components:

  • useLoaderData<T>() — the resolved return value of the nearest route’s loader.
  • useActionData<T>() — the return value of the most recent action, typically validation results.
  • useNavigate() — programmatic navigation; prefer <Link>/<NavLink>/<Form> for anything declarative.
  • useNavigation() — the global transition state (idle / loading / submitting) for pending UI.
  • redirect(url, init?) — returned or thrown from a loader/action to issue a navigation.

createBrowserRouter accepts the route array plus options such as basename. It relies on window.history and is therefore unavailable in a non-DOM environment without a polyfill such as jsdom.

Step-by-Step Implementation

Prerequisite: React 18.2+ and a bundler that supports dynamic import() (Vite or Webpack 5). Every code block below is TypeScript-first and assumes react-router-dom v6.22.

Step 1: Build the data router with a root layout

Define a root route whose element renders a persistent shell and an <Outlet />. Child routes render into that outlet without remounting the shell.

// react-router-dom v6.22 — TypeScript 5.x
import { createBrowserRouter, RouterProvider, Outlet, type RouteObject } from 'react-router-dom';
import { lazy, Suspense } from 'react';

// Route-level code splitting: these chunks load only when matched.
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Settings = lazy(() => import('./routes/Settings'));

const routes: RouteObject[] = [
  {
    path: '/',
    element: (
      <div className="app-shell">
        <Suspense fallback={<div aria-live="polite">Loading…</div>}>
          <Outlet />
        </Suspense>
      </div>
    ),
    errorElement: <div role="alert">Something went wrong.</div>,
    children: [
      { index: true, element: <Dashboard /> },
      { path: 'settings', element: <Settings /> },
    ],
  },
];

const router = createBrowserRouter(routes, {
  basename: import.meta.env.BASE_URL, // Vite exposes the configured base path here
});

export const AppRouter = () => <RouterProvider router={router} />;

Step 2: Attach loaders for parallel data fetching

A loader runs during the navigation transition, before the route’s element renders. Loaders for all matched routes fire in parallel, collapsing the waterfall into a single round of concurrent requests.

// react-router-dom v6.22 — TypeScript 5.x
import { useLoaderData, Outlet, type LoaderFunctionArgs } from 'react-router-dom';

interface DashboardData {
  metrics: Array<{ id: string; value: number }>;
  user: { name: string };
}

export async function dashboardLoader({ request }: LoaderFunctionArgs) {
  // Both requests fire simultaneously; request.signal cancels them on navigation.
  const [metricsRes, userRes] = await Promise.all([
    fetch('/api/metrics', { headers: { Accept: 'application/json' }, signal: request.signal }),
    fetch('/api/user', { headers: { Accept: 'application/json' }, signal: request.signal }),
  ]);

  // Throwing a Response routes control to the nearest errorElement.
  if (!metricsRes.ok || !userRes.ok) {
    throw new Response('Failed to load dashboard data', { status: 500 });
  }

  return {
    metrics: await metricsRes.json(),
    user: await userRes.json(),
  } satisfies DashboardData;
}

export const DashboardLayout = () => {
  const data = useLoaderData() as DashboardData;
  return (
    <div>
      <header aria-label="User greeting">Welcome, {data.user.name}</header>
      <main>
        <Outlet />
      </main>
    </div>
  );
};

Wire the loader into the route node: { index: true, element: <DashboardLayout />, loader: dashboardLoader }. The element never renders against undefined data, which removes a whole class of loading-state branches from your components.

Step 3: Handle mutations with actions

An action receives the submitted <Form> data and performs the write. Returning a redirect after a successful mutation is the canonical pattern; returning a plain object surfaces validation errors via useActionData.

// react-router-dom v6.22 — TypeScript 5.x
import { Form, redirect, useActionData, type ActionFunctionArgs } from 'react-router-dom';

interface ActionResult {
  error?: string;
}

export async function settingsAction({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const displayName = String(form.get('displayName') ?? '').trim();

  if (displayName.length < 2) {
    // Returned (not thrown): re-renders the route with these errors.
    return { error: 'Display name must be at least 2 characters.' } satisfies ActionResult;
  }

  await fetch('/api/settings', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ displayName }),
    signal: request.signal,
  });

  // Throwing or returning redirect issues a fresh navigation + revalidation.
  return redirect('/settings?saved=1');
}

export const SettingsForm = () => {
  const result = useActionData() as ActionResult | undefined;
  return (
    <Form method="post">
      <label htmlFor="displayName">Display name</label>
      <input id="displayName" name="displayName" />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button type="submit">Save</button>
    </Form>
  );
};

Step 4: Guard protected routes in the loader

Validate authorisation in the loader, before the protected chunk renders. Throw redirect to bounce unauthenticated users — this prevents a flash of protected content and stops the protected lazy chunk from rendering. For the trade-offs between client guards and other fallback routing strategies, the rule is the same: the client guard is convenience, the server is the boundary.

// react-router-dom v6.22 — TypeScript 5.x
import { redirect, type LoaderFunctionArgs } from 'react-router-dom';
import { lazy, Suspense } from 'react';

function isValidToken(token: string): boolean {
  return token.length > 0; // replace with real validation
}

const AdminPanel = lazy(() => import('./routes/AdminPanel'));

export async function adminGuardLoader({ request }: LoaderFunctionArgs) {
  const cookieHeader = request.headers.get('Cookie') ?? '';
  const token = cookieHeader.match(/auth_token=([^;]+)/)?.[1];

  if (!token || !isValidToken(token)) {
    // Preserve the intended destination for post-login return.
    throw redirect('/login?redirect=/admin');
  }
  return null;
}

export const AdminRoute = () => (
  <Suspense fallback={<div aria-live="polite">Loading…</div>}>
    <AdminPanel />
  </Suspense>
);

Step 5: Restore scroll and focus on navigation

React Router ships a <ScrollRestoration /> component for the common case, but bespoke restoration (per-key positions, focus to a heading) needs a hook. The pattern below saves and restores positions keyed by path, complementing the broader scroll restoration strategies used across SPAs.

// react-router-dom v6.22 — TypeScript 5.x
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export function useScrollRestoration() {
  const location = useLocation();
  const positions = useRef<Record<string, number>>({});

  useEffect(() => {
    // Restore the saved offset for this path, defaulting to the top.
    window.scrollTo(0, positions.current[location.pathname] ?? 0);

    const save = () => {
      positions.current[location.pathname] = window.scrollY;
    };
    // Capture the offset just before the next transition unmounts this view.
    return () => save();
  }, [location.pathname]);
}
Six React Router jobs and the API for each Six routing jobs paired with the data-router API that handles each, contrasted with the older or hand-rolled approach it replaces: route declaration, loaders, actions, params, error elements and pending state. Which React Router API for which job use instead of declaring routes createBrowserRouter <BrowserRouter> + <Routes> loading data a route loader useEffect in the component submitting data an action + <Form> a manual fetch handler reading params useParams, typed at the boundary parsing location yourself errors errorElement per route a boundary around the whole app pending UI useNavigation().state a local isLoading flag
The right-hand column is what most existing applications still do — every row moves work from components into the route table.

Verification & Testing

A Playwright spec is the most reliable way to confirm that loaders resolve before paint, that lazy chunks are deferred, and that guards redirect. The test below asserts the dashboard greeting is present on first paint (proving the loader ran before render) and that an unauthenticated visit to /admin lands on the login screen.

// @playwright/test v1.42 — TypeScript 5.x
import { test, expect } from '@playwright/test';

test('dashboard renders with loader data on first paint', async ({ page }) => {
  await page.goto('/');
  // No spinner-then-content flash: the greeting is present immediately.
  await expect(page.getByLabel('User greeting')).toBeVisible();
});

test('admin route redirects unauthenticated users', async ({ page }) => {
  await page.context().clearCookies();
  await page.goto('/admin');
  await expect(page).toHaveURL(/\/login\?redirect=\/admin/);
});

test('settings chunk is not in the initial bundle', async ({ page }) => {
  const requested: string[] = [];
  page.on('request', (r) => requested.push(r.url()));
  await page.goto('/');
  // The lazily-imported Settings chunk should not load until navigated to.
  expect(requested.some((u) => /Settings.*\.js/.test(u))).toBe(false);
});

For a quick manual check, open DevTools, throttle the network to Slow 3G, and watch the Network panel: with parallel loaders you should see the route’s requests start together at the same timestamp rather than staggered.

Performance Tuning

  • Defer non-critical data with defer and <Await>. Return a promise for slow data from the loader and stream it in after the shell paints, instead of blocking the whole transition on the slowest request.
  • Split at the route boundary, not the component boundary. A lazy() import per route gives the cleanest chunk graph; the router prefetches the matched chunk during the transition, so the cost is hidden behind navigation latency.
  • Prefetch on intent. <Link prefetch> (or a hover handler that warms the loader) starts data and chunk fetching before the click, cutting perceived latency to near zero on fast connections.
  • Cancel with request.signal. Threading the loader’s AbortSignal into every fetch frees sockets and avoids state updates from stale navigations — measurable as fewer in-flight requests in long sessions.
  • Keep history.state lean. Large serialised payloads slow every pushState; store identifiers and refetch, as covered under pushState and replaceState usage.

Moving Work Out of Components

The largest change between the older React Router style and the data-router style is not syntactic. It is a relocation of responsibility: fetching, error handling, pending state and revalidation move out of component bodies and into the route table.

That relocation is worth understanding on its own terms, because it explains why the migration is worth doing even in an application whose current routing works.

Fetching in a component is inherently a waterfall. An effect cannot run until its component renders, and a child cannot render until its parent has resolved. Three nested components that each fetch produce three sequential round trips, and no amount of caching removes the dependency — it is created by the render order. Route loaders sit above the components, so the router can start every level’s request the moment the URL is known, in parallel. On a three-level route with a hundred-millisecond backend, that is the difference between three hundred milliseconds and a hundred.

Pending state in a component is per-component. Each fetch grows its own isLoading flag, its own error state, and its own decision about what to render while waiting. Ten routes produce ten slightly different loading experiences, which is why applications drift toward inconsistent spinners. With a data router there is one navigation state for the whole application, and the shell decides once how a pending navigation looks.

Errors in a component are unstructured. A failed fetch inside an effect surfaces wherever the developer remembered to catch it, or does not surface at all. An errorElement on a route scopes failure to that route: the panel degrades, the layout survives, and the boundary resets automatically when the user navigates away.

Revalidation is the quiet one. After a mutation, something must re-fetch the data that mutation invalidated. In the component style this is manual, easy to forget, and the source of the “I saved it but the list still shows the old value” class of bug. With actions and loaders, the router revalidates the current route’s data after every action by default.

None of this requires adopting every feature at once. Converting one route’s fetch into a loader is a contained change, and it demonstrates the pattern more convincingly than any description does.

It is worth noting which of these change behaviour silently rather than loudly, because those are the ones that survive a review. Specificity ranking, exact-by-default matching and route-relative link resolution all compile cleanly and simply render something different — usually on the deepest screens, which are the least likely to be exercised during a quick manual pass. Everything else in this list fails visibly the first time it runs.

Gotchas & Failure Modes

  • Mixing the data router with <BrowserRouter>. Loaders, actions, and <Await> only work under createBrowserRouter + RouterProvider. Inside <BrowserRouter> they silently do nothing.
  • Forgetting to thread request.signal. Loaders without an abort signal leak requests; rapid navigation produces a pile of resolved-but-discarded fetches and occasional out-of-order state.
  • Throwing where you meant to return. A thrown value goes to the nearest errorElement; a returned value goes to the component. Validation errors should be returned, not thrown.
  • Misconfiguring basename for subpath deploys. A wrong basename breaks both asset resolution and route matching. Validate import.meta.env.BASE_URL against the deployment path.
  • SSR hydration mismatches. If a server renders a different route tree than the client constructs, React warns and discards markup. Keep the route definitions identical on both sides.
  • Guards as security. Loader redirects stop the render, not the request. Always enforce authorisation at the API; the client guard only improves the experience.
Where each concern lives in a data router Five routing concerns mapped to the part of the route definition that owns each: the loader fetches, the element renders, the error element handles failure, the parent element persists, and the path defines meaning. Where each concern lives in a data router concern owned by what to fetch the route's loader runs before the component renders what to render the route's element receives data already resolved what to render on error errorElement scoped to that route what the parent keeps its own element + <Outlet /> not remounted by children what the URL means the route path one place, not scattered
Each row moves a decision out of component bodies and into a table you can read end to end.

One more consideration worth settling early: how much of the router’s data layer to adopt. Loaders, actions, deferred data, revalidation and fetchers are separable, and adopting them in order of payoff is more productive than converting everything at once.

Loaders first, because they remove waterfalls and are the change with the largest measurable effect. Error elements second, because they are cheap and convert a class of blank screens into recoverable panels. Actions third, since they bring revalidation with them and eliminate the most common source of stale-list bugs. Deferred data last — it is genuinely useful for slow secondary calls, and it is also the feature that most complicates a route’s shape, so it is best introduced once the simpler pieces are established and the team is comfortable reading route definitions as the place where data lives.