How React Router works under the hood


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:
Faster initial page load (no client-server roundtrip)
SEO (content available in initial HTML)
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:
Server renders components to HTML
Client receives HTML
Client downloads React + your components (javascript bundles)
Client runs the components. This builds the virtual dom.
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:
Single network request
Server can optimize data loading
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:
Data might be stale after mutation
Parent/child routes often share data dependencies
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:
Latest navigation wins
Previous requests are aborted
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:
Each fetcher is independent
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:
Router state changes in context.
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
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.