Tags: web-dev reference

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 — loader and action — 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, immediately

Required versus optional

Required?Notes
loaderNoOmit for a route with no data
actionNoOnly if the route handles mutations
default exportYes, to renderA route can be data-only — an API endpoint with just a loader
ErrorBoundaryNoFalls back to the nearest ancestor’s, then a default
metaNoTitle and meta tags
headersNoResponse headers, including caching
linksNoStylesheets and preloads for this route
shouldRevalidateNoOpt out of the automatic re-run. Rarely correct
A serverYesThis is server-rendered. There is a static export path, with the loader model constrained accordingly
TypeScriptNoTypes 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.]


  • 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