How React Router works under the hood

Tiger AbrodiTiger Abrodi
7 min read

Introduction

This post dives into how React Router as a framework works under the hood. Remix recently merged with React Router.

React Router is now not only a routing library but also a full-stack web framework.

Preface

It’s not your typical post. I more or less took notes after going through the codebase and asking a lot of questions.

I hope the way I explain things isn’t too confusing. And to be clear, this is not a post for beginners. I do recommend you playing around with React Router as a framework, then coming back and reading this after a while.

React Router as a Framework: Deep Dive

Initial Request Types

When a request hits the server, React Router handles different types:

// Main request types
if (url.pathname === manifestUrl) {
  return handleManifestRequest(); // /__manifest for fog-of-war
} else if (url.pathname.endsWith(".data")) {
  return handleDataRequest(); // /route.data for client navigations
} else {
  return handleDocumentRequest(); // Full page load
}

/__manifest requests happen when client needs new route info. .data requests are for client-side navigation data loading (after initial page load). Document requests are full page loads (initial page load).

Initial page load happens on the first request. When you refresh the page, it's a full page load! After initial SSR, React Router is client-side only (unless you use `reloadDocument).

__manifest is quite cool. If you explore the network tab, you'll see __manifest requests. It returns something like this (taken from my multiplayer naruto game):

🍿 See JSON
{
  "routes/views/auth/root": {
    "id": "routes/views/auth/root",
    "parentId": "root",
    "path": "/",
    "hasAction": false,
    "hasLoader": true,
    "hasClientAction": false,
    "hasClientLoader": false,
    "hasErrorBoundary": false,
    "module": "/assets/root-D2vetjBp.js",
    "imports": [
      "/assets/utils-zTSokisb.js",
      "/assets/index-BEb7FAyx.js",
      "/assets/card-C3OqSkw-.js",
      "/assets/constants-Be6MM4va.js",
      "/assets/constants-pAWeGExn.js",
      "/assets/index-DNU-sf_S.js",
      "/assets/index-3TgtNGEO.js",
      "/assets/index-q15GdxUK.js",
      "/assets/index-DXbShEkv.js"
    ],
    "css": []
  },
  "routes/views/auth/login": {
    "id": "routes/views/auth/login",
    "parentId": "routes/views/auth/root",
    "path": "login",
    "hasAction": true,
    "hasLoader": true,
    "hasClientAction": false,
    "hasClientLoader": false,
    "hasErrorBoundary": false,
    "module": "/assets/login-DZz-useP.js",
    "imports": [
      "/assets/utils-zTSokisb.js",
      "/assets/index-BEb7FAyx.js",
      "/assets/label-P4RMUw0u.js",
      "/assets/button-B1mfwelL.js",
      "/assets/constants-Be6MM4va.js",
      "/assets/constants-pAWeGExn.js",
      "/assets/input-DqgOOe5U.js",
      "/assets/createLucideIcon-DWA2PLD3.js",
      "/assets/index-DNU-sf_S.js",
      "/assets/index-3TgtNGEO.js",
      "/assets/index-q15GdxUK.js",
      "/assets/index-DXbShEkv.js"
    ],
    "css": []
  }
}

Fog of War in React Router is a smart way to improve performance. Instead of sending all the route information to the client at once, it only sends the routes needed for the current page. As users move around, it finds and loads new routes when necessary.

The hook being used: useFogOFWarDiscovery. It also respects the user's preferences via navigator.connection.saveData. FYI - that's not available in all browsers.


Initial Page Load

Server starts by running loaders and preparing state:

// Server-side
let context = await staticHandler.query(request, {
  requestContext: loadContext,
});

// Prepare state for client hydration

// Server UI state to send to the client.
// - When single fetch is enabled, this is streamed down via `serverHandoffStream`
// - Otherwise it's stringified into `serverHandoffString`
let state = {
  loaderData: context.loaderData,
  actionData: context.actionData,
  errors: serializeErrors(context.errors, serverMode),
};
let entryContext: EntryContext = {
  manifest: build.assets,
  routeModules: createEntryRouteModules(build.routes),
  staticHandlerContext: context,
  criticalCss,
  serverHandoffString: createServerHandoffString({
    basename: build.basename,
    criticalCss,
    future: build.future,
    isSpaMode: build.isSpaMode,
  }),
  serverHandoffStream: encodeViaTurboStream(
    state,
    request.signal,
    build.entry.module.streamTimeout,
    serverMode
  ),
  renderMeta: {},
  future: build.future,
  isSpaMode: build.isSpaMode,
  serializeError: (err) => serializeError(err, serverMode),
};

This code can be found inside handleDocumentRequest.

This state gets sent to the client for hydration. React Router does SSR first. Some benefits:

  1. Faster initial page load (no client-server roundtrip)

  2. SEO (content available in initial HTML)

  3. Progressive enhancement (works without JS)


Hydration Process

There are two ways to approach hydration. One where you set clientLoader.hydrate = true and one where you don't.

Let's look at both.

Without clientLoader.hydrate = true

export async function loader() {
  /*
    Do some server stuff
  */
}

export async function clientLoader() {
  await doSomeClientStuff();
  return data;
}

In this case, clientLoader doesn't participate in the hydration process. It won't run on the initial page load.

"Hydration" is a term often thrown around. If you want to learn this in-depth (you can just read the first few sections), read: Why are React Server Components actually beneficial? (full history)

Let's understand what's happening here:

  1. Server renders components to HTML

  2. Client receives HTML

  3. Client downloads React + your components (javascript bundles)

  4. Client runs the components. This builds the virtual dom.

  5. React walks the virtual DOM tree and the real DOM tree in parallel. Matches them up node by node. Attaches event handlers to the real DOM nodes. Sets up state management and makes everything interactive.

With clientLoader.hydrate = true

export async function loader() {
  /*
    Do some server stuff
  */
}

export function HydrateFallback() {
  return <Loading />;
}

export async function clientLoader() {
  await doSomeClientStuff();
  return data;
}

clientLoader.hydrate = true;

If clientLoader.hydrate = true, RR shows HydrateFallback during hydration. This is important when client initialization is needed before the UI is useful. Without HydrateFallback, users see a flash of server content before client data loads. If this isn't an issue for you, you likely don't need hydrate = true!


Client-Side Navigation

When navigating, React Router uses a single-fetch strategy:

// Instead of separate fetches per loader:
// /posts.data
// /posts/$id.data
// /posts/$id/comments.data

// Single fetch with all loader data:
fetch("/posts/$id/comments.data?_routes=posts,post,comments");

This is more efficient because:

  1. Single network request

  2. Server can optimize data loading

  3. Reduced waterfall effect


Actions and Revalidation

When an action completes, all current route loaders revalidate:

// Nested routes
/app
  /dashboard
    /profile

// After action on /dashboard
// All these revalidate:
- app loader
- dashboard loader
- profile loader

RR revalidates all loaders because:

  1. Data might be stale after mutation

  2. Parent/child routes often share data dependencies

  3. Ensures UI consistency

You can opt out for a specific route with shouldRevalidate if needed. It’s worth mentioning that it’s super easy to shoot yourself in the foot here. If you do opt out, it’s like breaking hooks rule in React. It’s very easy for your data to get out of sync if you don’t know what you’re doing. If I use it, I always add a clear comment explaining the why.


Race Conditions

RR automatically handles navigation race conditions:

// If user clicks quickly:
navigate("/a");
navigate("/b"); // Cancels /a navigation
navigate("/c"); // Cancels /b navigation

This works because:

  1. Latest navigation wins

  2. Previous requests are aborted

  3. Matches browser behavior

The code for this happens in the startNavigation function. React Router uses AbortController to cancel previous requests. My friend Artem recently wrote an amazing post about it: Don't Sleep on AbortController.

Fun fact: Link (source) components use navigate internally. The hook they use can be found in the lib.tsx#L1278.


Same applies to form submissions, but fetchers work differently:

  1. Each fetcher is independent

  2. Can only interrupt itself

If you need multiple concurrent submissions, read: How to handle multiple concurrent submissions in Remix.


Router State and UI Updates

React Router maintains state through context:

<DataRouterContext.Provider value={router}>
  <DataRouterStateContext.Provider value={state}>
    {children}
  </DataRouterStateContext.Provider>
</DataRouterContext.Provider>

This can be found in RouterProvider.

When state changes (navigation/action), UI updates because:

  1. Router state changes in context.

  2. Components that consume the context re-render. (this is how React itself works!)

This is why hooks like useLoaderData() work automatically. They're subscribed to router state changes.

A quick look into useLoaderData:

export function useLoaderData<T = any>(): SerializeFrom<T> {
  let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
  let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
  return state.loaderData[routeId] as SerializeFrom<T>;
}

DataRouterStateHook is just an enum:

enum DataRouterStateHook {
  UseBlocker = "useBlocker",
  UseLoaderData = "useLoaderData",
  UseActionData = "useActionData",
  UseRouteError = "useRouteError",
  UseNavigation = "useNavigation",
  UseRouteLoaderData = "useRouteLoaderData",
  UseMatches = "useMatches",
  UseRevalidator = "useRevalidator",
  UseNavigateStable = "useNavigate",
  UseRouteId = "useRouteId",
}

However, more recently, you get loader and action data as props:

export default function Component({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.name}</h1>;
}

How does this work?

First and foremost, where they do their "on the fly magic" type generation: generate.ts.

Now, where exactly they "inject" the props, that's a vite plugin.

Recap

I spent 8 hours digging into this. It sounds like a lot, but it's just a day. It's not like I spent weeks or months on this.

The point?

Give yourself some time, and you can actually understand how things work.

It's not magic like Ginger Bill says :3

8
Subscribe to my newsletter

Read articles from Tiger Abrodi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tiger Abrodi
Tiger Abrodi

Just a guy who loves to write code and watch anime.