Reference - React Router
Meta-framework: React Router v7, framework mode — a library, a data layer and a framework in one package. See Libraries, Frameworks and Toolkits
Checked: 2026-08-18
The routing and data layer that Remix became. Learn two functions —
loaderandaction— and you have most of it. Its premise is that the web platform already solved data loading and mutation, and that single-page apps re-implemented it badly.
The three modes
React Router ships one package that can be used at three levels of commitment. The name covers all three, which is the first thing to get straight.
DECLARATIVE MODE <BrowserRouter>, <Routes>, <Route>
a client-side router and nothing else.
what most people mean by "React Router"
from the last decade
DATA MODE createBrowserRouter, loaders and actions,
still client-only — you supply the server
FRAMEWORK MODE the above plus a server, a build, SSR,
file conventions and type generation
← THIS is what Remix became, and what
this reference covers
Everything below is framework mode. The loader/action model appears in data mode too, without the server half.
How this relates to Remix
Remix v2 was merged into React Router in December 2024. Everything that made Remix distinctive — loaders, actions, nested routes, Form — is now React Router’s framework mode, and new projects start here rather than with Remix.
The name “Remix” now belongs to a separate, React-free project, which is a different thing entirely and covered in Reference - Remix. Shopify’s Hydrogen runs on React Router v7, whatever older documentation calls it.
§0 The one idea
The server owns the data, and the URL is the state. A route is not a component that fetches — it’s a URL with a function that supplies data for GET and a function that handles POST.
vs a React SPA: in a typical SPA you render a component, it mounts, an effect fires, a fetch starts, you juggle loading and error state, and the request waterfall is however deep your component tree is. Remix inverts it — data is resolved before the component renders, in parallel across all matched routes, on the server.
REACT SPA REACT ROUTER, FRAMEWORK MODE
render shell request arrives at a URL
↓ ↓
component mounts match ALL nested routes
↓ ↓
useEffect fires run every loader IN PARALLEL
↓ ↓
fetch starts ← waterfall render with data already present
↓ ↓
setState, re-render send HTML
↓
child mounts, fetches again mutation → <form> POST → action
→ loaders re-run automatically
mutation → fetch → manually
invalidate your cache
The two consequences worth holding. There is no request waterfall, because nesting is known from the URL before any component renders. And there is no client cache to invalidate, because after a mutation the framework re-runs the loaders — the server is the source of truth, so “refetch everything on this page” is the default rather than a thing you orchestrate.
1. The smallest working form
A complete route. Nothing omitted.
// app/routes/product.tsx
export async function loader({ params }) {
const product = await db.product.find(params.sku);
return { product };
}
export default function Product({ loaderData }) {
return <h1>{loaderData.product.name}</h1>;
}That’s the whole thing. loader runs on the server for a GET. Its return value arrives as loaderData. No fetching in the component, no loading state, no effect.
The loader is server-only — it is not in the client bundle. Database credentials, private API keys and server SDKs can be used directly in it, which is the practical reason this model is pleasant.
2. Mutations — action and Form
The second and last function you need.
import { Form, redirect } from "react-router";
export async function loader({ params }) {
return { basket: await getBasket(params.id) };
}
export async function action({ request, params }) {
const formData = await request.formData(); // a real FormData
await addToBasket(params.id, formData.get("sku"));
return redirect(`/basket/${params.id}`);
}
export default function Basket({ loaderData }) {
return (
<Form method="post">
<input type="hidden" name="sku" value="AB-123" />
<button type="submit">Add to basket</button>
</Form>
);
}Form is an HTML <form> with the navigation intercepted. It submits to the route’s action, and when the action finishes, every loader on the page re-runs automatically. You never write “and now refresh the basket count in the header” — the header’s loader re-ran too.
request is a standard Request and the return value is a standard Response. formData(), headers, redirect() — these are the web platform’s, not the framework’s. That is the “web standards” claim, and it’s the thing that makes the knowledge transfer: what you learn here works in a service worker, in an edge function, in Deno.
3. Nested routes
The feature the whole design is built around. URL segments map to nested layouts, and each level has its own loader.
URL: /account/orders/1234
app/routes/
account.tsx → loader: the logged-in user
account.orders.tsx → loader: the order list
account.orders.$id.tsx → loader: THIS order
all three loaders run IN PARALLEL, not in sequence
// app/routes/account.tsx — a layout route
import { Outlet } from "react-router";
export async function loader({ request }) {
return { user: await requireUser(request) };
}
export default function Account({ loaderData }) {
return (
<div>
<nav>Signed in as {loaderData.user.name}</nav>
<Outlet /> {/* the child route renders here */}
</div>
);
}Why parallel matters: the framework knows from the URL which routes match, so it can start all three loaders at once. A component-tree-driven SPA cannot — it has to render the parent to discover the child. That’s the waterfall, and this is the structural fix for it — Data Fetching Patterns.
Navigating between siblings only re-runs what changed. Moving from order 1234 to order 5678 re-runs the innermost loader; the account layout’s loader is not called again.
4. Pending and error states
Both are route-level rather than per-component.
import { useNavigation } from "react-router";
export default function Product({ loaderData }) {
const navigation = useNavigation();
const busy = navigation.state !== "idle"; // "idle" | "loading" | "submitting"
return <button disabled={busy}>{busy ? "Adding…" : "Add to basket"}</button>;
}
// exported from the same file — catches errors from loader, action AND render
export function ErrorBoundary({ error }) {
return <p>Sorry — {error.message}</p>;
}The ErrorBoundary is scoped to its route. An error in the order-detail loader renders the boundary inside the account layout — the navigation, the user’s name and the rest of the page still work. That’s nested routing paying off a second time, and it’s a genuinely better default than a blank screen.
5. Progressive enhancement, and why it’s the honest kind
Because mutations are real forms posting to real URLs, the page works before JavaScript loads and if it never loads.
JS not yet loaded <form> posts natively → action runs → redirect → new page
slower, correct
JS loaded Form intercepts → fetch → action runs → loaders re-run
→ re-render in place. no full navigation
The same code produces both. You don’t write a no-JS fallback; you write the form and the enhancement is the framework’s job. This is the strongest single argument for the model, and it’s the part most React frameworks can’t claim honestly.
6. useFetcher — mutation without navigation
For anything that shouldn’t change the URL: an add-to-basket that stays on the page, a newsletter signup, a quantity stepper.
import { useFetcher } from "react-router";
function AddToBasket({ sku }) {
const fetcher = useFetcher();
const adding = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action="/basket/add">
<input type="hidden" name="sku" value={sku} />
<button disabled={adding}>{adding ? "Adding…" : "Add"}</button>
</fetcher.Form>
);
}Form navigates; fetcher.Form doesn’t. Both re-run the loaders afterwards. Several fetchers can be in flight independently, each with its own state — which is what you want on a listing page with an add button per card.
Optimistic UI is read off fetcher.formData, which holds what’s being submitted right now:
const pendingSku = fetcher.formData?.get("sku"); // render it as added, immediatelyRequired versus optional
| Required? | Notes | |
|---|---|---|
loader | No | Omit for a route with no data |
action | No | Only if the route handles mutations |
default export | Yes, to render | A route can be data-only — an API endpoint with just a loader |
ErrorBoundary | No | Falls back to the nearest ancestor’s, then a default |
meta | No | Title and meta tags |
headers | No | Response headers, including caching |
links | No | Stylesheets and preloads for this route |
shouldRevalidate | No | Opt out of the automatic re-run. Rarely correct |
| A server | Yes | This is server-rendered. There is a static export path, with the loader model constrained accordingly |
| TypeScript | No | Types are generated per route and worth having |
The kitchen sink
Every route export at once, which no real route has:
export const meta = ({ data }) => [{ title: data.product.name }];
export const links = () => [{ rel: "stylesheet", href: productStyles }];
export const headers = () => ({ "Cache-Control": "max-age=300, s-maxage=3600" });
export async function loader({ params, request, context }) { /* GET */ }
export async function action({ params, request, context }) { /* POST/PUT/DELETE */ }
export function shouldRevalidate({ currentUrl, nextUrl, formMethod }) {
return true; // the default. changing this is a smell
}
export function ErrorBoundary({ error }) { /* loader, action or render errors */ }
export function HydrateFallback() { /* shown while client-only data resolves */ }
export default function Route({ loaderData, actionData }) { /* the component */ }A complete route, top to bottom
A product page with an add-to-basket, error handling and caching.
// app/routes/product.$sku.tsx
import { Form, useNavigation, redirect } from "react-router";
export const meta = ({ data }) =>
data ? [{ title: `${data.product.name} | Shop` }] : [{ title: "Not found" }];
export const headers = () => ({
"Cache-Control": "max-age=60, s-maxage=600, stale-while-revalidate=86400",
});
export async function loader({ params }) {
const product = await db.product.findBySku(params.sku);
if (!product) throw new Response("Not found", { status: 404 }); // → ErrorBoundary
return { product, stock: await stockFor(params.sku) };
}
export async function action({ request, params }) {
const form = await request.formData();
const qty = Number(form.get("quantity") ?? 1);
if (qty < 1) return { error: "Quantity must be at least 1" }; // → actionData
await basket.add(params.sku, qty);
return redirect("/basket");
}
export default function Product({ loaderData, actionData }) {
const { product, stock } = loaderData;
const busy = useNavigation().state === "submitting";
return (
<article>
<h1>{product.name}</h1>
<p>£{(product.pricePence / 100).toFixed(2)}</p>
<Form method="post">
<label htmlFor="qty">Quantity</label>
<input id="qty" name="quantity" type="number" defaultValue={1} min={1} />
{actionData?.error && <p role="alert">{actionData.error}</p>}
<button disabled={busy || stock === 0}>
{stock === 0 ? "Out of stock" : busy ? "Adding…" : "Add to basket"}
</button>
</Form>
</article>
);
}
export function ErrorBoundary({ error }) {
const notFound = error?.status === 404;
return <p>{notFound ? "We couldn't find that product." : "Something went wrong."}</p>;
}Notice what isn’t there: no useState, no useEffect, no loading flag you maintain, no cache invalidation after the mutation, and no separate no-JavaScript path.
The round trip
One value, followed all the way through — the thing that makes the model click.
1 GET /product/AB-123
↓
2 route matched, params.sku = "AB-123"
↓
3 loader runs ON THE SERVER, in parallel with any parent loaders
↓
4 { product, stock } serialised into the HTML response
↓
5 component renders with loaderData — no fetch, no effect, no spinner
↓
6 user submits the Form → POST to the same URL
↓
7 action runs on the server, reads request.formData()
↓
8 returns redirect("/basket")
↓
9 ALL loaders for the new URL re-run automatically
↓
10 fresh data renders. nothing was invalidated by hand
Step 9 is the payoff. In an SPA that step is your problem — a cache key, a query invalidation, a manual refetch. Here it’s the framework’s, because the server was the source of truth throughout.
[CHECK: exact package names and import specifiers. Remix v2 imported from @remix-run/react and @remix-run/node; framework mode imports from react-router. Verify against the current docs before writing code against this note.]
Related
- Rendering Strategies — where server rendering with client hydration sits among the options
- Hydration — the cost this model still pays, and what it buys
- Progressive Enhancement — the property §5 delivers, and why it’s rare to get honestly
- Forms — the native behaviour the whole mutation model is built on
- Reference - React — the library underneath; this note deliberately doesn’t re-document it
- Reference - Next.js — the main alternative, with a different answer to the same questions
- Reference - Remix — the name this framework used to carry, and the unrelated project that carries it now