Data Routers, Loaders and Actions

Section 2.16 answered one question: which component belongs at this URL? The other half of a router's job is the data that component needs, and declarative mode leaves it to you — every screen fetches in an Effect, tracks its own flags, and starts only after it has mounted.A data router moves fetching in front of rendering. A route's loader runs as soon as the URL matches — in parallel across the matched chain — and its action takes the write, after which every loader on the page runs again so the screen cannot go stale.Figure 2.56: One navigation through a data router, from click to painted screenEvery API below except useOutletContext throws outside a data router, so the first question is whether to opt in at all.

2.17.1 Why Data Routers Exist

Start with the code a declarative router forces you to write: mount, fire an Effect, paint a spinner while it waits.

function Product() {

const { id } = useParams();

const [product, setProduct] = useState(null);

useEffect(() => {

let live = true; setProduct(null);

fetch(`/api/products/${id}`).then((r) => r.json())

.then((d) => live && setProduct(d));

return () => { live = false; }; // ignore an out-of-order response

}, [id]);

return product ? <h2>{product.name}</h2> : <p>Loading...</p>;

}

Listing 2.145: Fetching in the component, the declarative way Eleven lines, one about products, and no error branch yet. The live flag guards against out-of-order responses (Section 2.4.5), the state exists because the component renders before its data, and the fetch starts after the route has rendered — behind any parent layout that fetches too. An Effect cannot run before its component exists, so that waterfall is structural.A data router inverts the order: the route declares where its data comes from, the router fetches for every matched route at once, and the component becomes return <h2>{useLoaderData().name}</h2>;. The rest becomes router behavior — parallel loaders, useNavigation().state as the loading flag, route error boundaries, cancelled stale loads, revalidation after every write.The costs are real. Route trees become objects rather than JSX, data lives per route, so two components needing the same record must sit under the route that loads it, and the router becomes the thing that knows your API — awkward if TanStack Query (Section 2.19.9) already owns that role. History: Loaders and actions arrived in React Router 6.4 in September 2022, back-ported from Remix. The projects then converged: Remix's router became React Router's data mode, its framework layer React Router's framework mode.

2.17.2 createBrowserRouter and RouterProvider

A data router is built before rendering, not during it. createBrowserRouter(routes, options) takes an array of plain route objects and returns a router instance; <RouterProvider router={router} /> is then the only React Router element in your tree — no <Routes>, no <Route>. Later examples assume this scaffolding.

import { createBrowserRouter } from 'react-router';

import { RouterProvider } from 'react-router/dom';

const router = createBrowserRouter([

{ path: '/', Component: Layout, ErrorBoundary: RootError, children: [

{ index: true, Component: Home },

{ path: 'products', Component: List, loader: listLoader },

{ path: 'products/:id', Component: Detail, loader: detailLoader, action: saveProduct },

{ path: '*', Component: NotFound },

] },

], { basename: '/app' });

createRoot(document.getElementById('root')).render(<RouterProvider router={router} />);

Listing 2.146: A data router, created once at module scope Note the import split: route objects, hooks and components come from react-router, but RouterProvider comes from react-router/dom, the build that wires the router into React DOM's flushSync; the one exported from react-router is the fallback for non-DOM renderers. In v7 both lived in react-router-dom, a package that no longer exists, so this is the mistake v8 newcomers make most often.A route object accepts path, index, children, Component or element, ErrorBoundary or errorElement, loader, action, middleware, lazy, handle, shouldRevalidate, id and caseSensitive. The capitalized fields take functions, not elements, which is what lets lazy swap them in later.Create the router outside the component that renders it: calling createBrowserRouter during render builds a new router every time, throwing away history and loader data. createHashRouter, createMemoryRouter (for tests, via initialEntries) and createStaticRouter share the signature. Beyond basename, the options carry hydrationData (a server render's loader data, so the client does not refetch on hydration), getContext for middleware (Section 2.17.14) and patchRoutesOnNavigation for routes discovered on demand. Warning: "useLoaderData must be used within a data router" usually means a stray <BrowserRouter> still wraps the tree above <RouterProvider>. Remove it; a data router is the whole router.

2.17.3 Route Loaders and useLoaderData

A loader is an async function on a route object. React Router calls it with { request, params, context } the moment the route matches, waits for the promise, and only then renders the route's component, which reads the result with useLoaderData() — there is no loading state, because the component never exists without its data. params holds the URL parameters; request is a standard Request, so the query string is new URL(request.url).searchParams and cancellation is request.signal. Drop the routes below into the array from Section 2.17.2.

{ path: '/staff', Component: Layout, id: 'staff',

loader: () => fetch('/api/staff/summary').then((r) => r.json()),

children: [

{ path: ':id', Component: Person, loader: ({ params, request }) =>

fetch(`/api/staff/${params.id}`, { signal: request.signal })

.then((r) => r.json()) },

] }

const Layout = () => <><h2>{useLoaderData().count} people</h2><Outlet /></>;

const Person = () => <p>{useLoaderData().name}</p>; // only this route's data

Listing 2.147: A parent and a child, each with its own loader Data is scoped per route: Person sees only what :id returned and Layout only what /staff returned. To read another route's data, give that route an id — as above — and call useRouteLoaderData('staff').A loader may also return a Response: redirect('/login') is followed before anything renders, and data(value, { status, headers }) attaches a status to a plain value. The json() helper was removed in React Router 7; return the object itself.After any action the router re-runs every loader on the page; a route whose data cannot have changed opts out with shouldRevalidate, which receives currentParams, nextParams, formMethod and defaultShouldRevalidate. Loaders run in parallel across the matched chain, so a three-level tree costs one round trip, and a navigation that supersedes them aborts their request.signal — the race condition of Section 2.4.5, handled once.

2.17.4 Route Actions

An action is the write half of a route. It runs whenever a non-GET submission targets that route, receives the same { request, params, context } argument as a loader, and is the only place a data-router app should mutate data.

async function createProduct({ request }) {

const form = await request.formData();

const name = String(form.get('name') || '').trim();

const price = Number(form.get('price'));

const errors = {};

if (name.length < 2) errors.name = 'Name must be at least 2 characters.';

if (!(price > 0)) errors.price = 'Price must be positive.';

if (Object.keys(errors).length) return { errors, values: { name, price } };

const response = await fetch('/api/products', { method: 'POST',

body: JSON.stringify({ name, price }), signal: request.signal });

if (!response.ok) throw data('Could not save the product', { status: 502 });

return redirect(`/products/${(await response.json()).id}`);

}

Listing 2.148: An action that validates, writes, then redirects Three return paths, and the difference between them is the whole design. Returning a value leaves the user on the form and hands that value to useActionData() — the validation-error case. Returning redirect(url) is what a successful write should do: the back button and a reload then both behave. Throwing hands control to the route's error boundary (Section 2.17.10).request.method distinguishes POST from PUT and DELETE, the body reads as formData(), json() or text(), and request.signal aborts if the user navigates away mid-submission. When the action resolves, the router revalidates every active loader, so the list the product was added to refreshes without a line of code. Never mutate from a component, an Effect or an event handler: a change that skips the action or fetcher is one the router never hears about. Tip: Validate in the action, not only in the browser. In framework mode (Section 2.17.15) the identical function runs on the server, where it is the only line of defense.

2.17.5 Form and useSubmit

<Form> from react-router renders a real <form> and intercepts its submit event. Instead of a full page load, the router serializes the fields into a FormData, calls the matching route's action, revalidates the loaders and re-renders — client-side, but with the browser's own semantics preserved.

<Form method="post" action="/products/new" replace>

<label>Name <input name="name" /></label>

<label>Price <input name="price" type="number" step="0.01" /></label>

<button type="submit" name="intent" value="save">Save</button></Form>

Listing 2.149: A Form posting to its own route's action method defaults to get, which is no mutation: the fields become a query string on the current route and the loaders re-run — what a search form wants. Any other method routes to the action. action names the route to submit to, replace swaps the history entry instead of pushing one, encType selects application/x-www-form-urlencoded (the default), multipart/form-data or application/json, and navigate={false} turns the submission into a fetcher (Section 2.17.9). The fields are real DOM inputs needing no React state: name is the contract with request.formData(), and a submit button carrying its own name/value — the intent pattern above — lets one form drive several branches of an action.useSubmit() returns an imperative submit(target, options) — target is a form element, a FormData, a URLSearchParams, a plain object or null — for submissions that no button triggers.

function Search() {

const submit = useSubmit(), timer = useRef(null);

function handleChange(event) {

const form = event.currentTarget;

clearTimeout(timer.current);

timer.current = setTimeout(() => submit(form, { replace: true }), 300);

}

return <Form method="get" onChange={handleChange}>

<input type="search" name="q" defaultValue={useLoaderData().q} /></Form>;

}

Listing 2.150: A search box that submits 300 ms after the last keystroke The loader reads new URL(request.url).searchParams.get('q') and returns the matches, so the query lives in the URL and the page is shareable. replace: true keeps 20 keystrokes from becoming 20 history entries, and defaultValue rather than value leaves typing to the browser while the loader catches up. Otherwise prefer <Form>: it keeps working, as a plain HTML form, before JavaScript has loaded.

2.17.6 useActionData

useActionData() returns whatever the current route's action returned, from its most recent call. Before any submission it is undefined, which makes it the natural home for validation errors rendered next to the fields that caused them.

function Signup() {

const r = useActionData(); // undefined until a submit

const field = (name, label) => <>

<label>{label} <input name={name} defaultValue={r?.values?.[name]} /></label>

{r?.errors?.[name] && <p className="err">{r.errors[name]}</p>}</>;

return <Form method="post">{field('username', 'Username')}

{field('email', 'Email')}<button type="submit">Sign up</button></Form>;

}

Listing 2.151: Validation errors redisplayed beside the form The route's action is the one from Section 2.17.4: validate, and return { errors, values } instead of redirecting when a field is wrong. Submitting jo and jo.example.com leaves the user on the same route with both messages under their fields and both typed values back in the inputs, because r.values came along for the ride.useLoaderData() always has a value, because its loader ran before the component existed; useActionData() starts empty and fills in after a round trip. It is scoped to the route that owns the action, which is why a successful action normally ends in redirect(). Note: useActionData() survives until the next submission to that route or until you navigate away. A stale error left on screen after the user fixed the field is the usual symptom of forgetting that.

2.17.7 useNavigation

useNavigation() has nothing to do with useNavigate() (Section 2.16.14). useNavigate changes the URL; useNavigation is a read-only report on a navigation already under way. It returns one object for the whole router, so a single call in the root layout drives a global progress bar.navigation.state is "idle", "loading" (loaders running for the next location) or "submitting" (an action running). While it is not idle, navigation.location is the destination, and formData, formMethod, formAction and formEncType describe a submission in flight.

function Layout() {

const { state } = useNavigation();

return <><div className="bar" hidden={state === 'idle'} /><Outlet /></>;

}

function SaveButton() {

const nav = useNavigation();

const saving = nav.state === 'submitting' && nav.formMethod === 'post';

return <button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>;

}

Listing 2.152: A global bar and a disabled submit button Two refinements matter in practice. A fast loader makes a spinner flash, so guard the indicator behind a short delay. And because the object is router-wide, a button watching state === 'submitting' also lights up for a submission started elsewhere — check formAction to narrow it, or use a fetcher, which has its own state. navigation.location names the destination before its data arrives — that is how <NavLink> sets isPending.

2.17.8 useRevalidator

useRevalidator() returns { revalidate, state }. Calling revalidate() re-runs the loaders of every route currently on screen without navigating: the URL does not change and nothing unmounts. state is "idle" or "loading", reported separately from useNavigation().state.You do not need it after an action. Reach for it when data changes outside the router's flow: a WebSocket push, a polling interval, another tab, or the window regaining focus.

function Dashboard() {

const stats = useLoaderData(), { revalidate, state } = useRevalidator();

useEffect(() => {

const onVisible = () => document.visibilityState === 'visible' && revalidate();

document.addEventListener('visibilitychange', onVisible);

return () => document.removeEventListener('visibilitychange', onVisible);

}, [revalidate]);

return <section aria-busy={state === 'loading'}>Open: {stats.open}</section>;

}

Listing 2.153: Refreshing when the tab becomes visible again revalidate is a stable reference, so it is safe in a dependency array. The call reloads the active loaders in full, not a diff, so polling a heavy route every second is a mistake; poll a cheap route through a fetcher (Section 2.17.9) instead. And a loader that throws during a revalidation lands in that route's error boundary, replacing a perfectly good screen.

2.17.9 useFetcher

Some interactions should not count as navigation: a like button, a checkbox in one table row, a combobox that queries as you type. useFetcher() returns a fetcher that calls a route's loader and action on its own, with its own state and data, and no effect on the URL or the history stack.

<style>body{font:15px/1.6 system-ui;margin:12px}ul{list-style:none;padding:0}

li{margin:4px 0}button{font:inherit}.done{color:#2e7d32}.busy{color:#ef6c00}</style>

<div id="root"></div>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",

"react-dom":"https://esm.sh/react-dom@19.3.0?external=react",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",

"react-router":"https://esm.sh/react-router@8.4.0?external=react,react-dom",

"react-router/dom":"https://esm.sh/react-router@8.4.0/dom?external=react,react-dom"}}</script>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

<script type="text/babel" data-type="module">

import React from 'react';

import { createRoot } from 'react-dom/client';

import { createMemoryRouter, useFetcher, useLoaderData } from 'react-router';

import { RouterProvider } from 'react-router/dom';

const db = [{ id: '1', title: 'Buy milk', done: false },

{ id: '2', title: 'Walk the dog', done: true }, { id: '3', title: 'Reply', done: false }];

function Item({ todo }) {

const fetcher = useFetcher();

// Trust the value being sent instead of waiting for the round trip.

const done = fetcher.formData ? fetcher.formData.get('done') === 'true' : todo.done;

return <li><fetcher.Form method="post" style={{ display: 'inline' }}>

<input type="hidden" name="id" value={todo.id} />

<input type="hidden" name="done" value={String(!done)} />

<button>{done ? 'Undo' : 'Mark done'}</button>

</fetcher.Form>{' '}<span className={done ? 'done' : ''}>{todo.title}</span>

{fetcher.state !== 'idle' && <span className="busy"> saving...</span>}</li>;

}

const router = createMemoryRouter([

{ path: '/', loader: () => db,

Component: () => <ul>{useLoaderData().map((t) => <Item key={t.id} todo={t} />)}</ul>,

action: async ({ request }) => {

const f = await request.formData();

await new Promise((r) => setTimeout(r, 400)); // a slow network

db.find((t) => t.id === f.get('id')).done = f.get('done') === 'true';

return { ok: true };

} }]);

createRoot(document.getElementById('root')).render(<RouterProvider router={router} />);

const click = (i, ms) => setTimeout(() => document.querySelectorAll('button')[i].click(), ms);

click(0, 300); click(2, 5800); // the second is still in flight at capture

</script>

Listing 2.154: Per-row submissions with optimistic labels Figure 2.57: Browser output of Listing 2.154The first row has finished; the third was clicked moments before the screenshot and shows the optimistic result of a submission still in the air. That optimism is the line reading fetcher.formData: in-flight values are readable while the action runs, so the label flips instantly. The other rows stayed idle — each useFetcher() call owns a separate state machine.Besides Form, a fetcher exposes load(href) to run a loader imperatively, submit(target, options) to call an action, reset(), and the read-only state, data, formData, formMethod, formAction, formEncType, json and text. useFetcher({ key: 'cart' }) makes two components share one fetcher, so a header badge can render a submission started in the page body.The router still revalidates every loader when the action resolves; what a fetcher skips is the navigation — no history entry, no useNavigation state, nothing unmounted. Use <Form> when the submission should feel like going somewhere, a fetcher when it should feel like editing what is on screen.

2.17.10 useRouteError

When a loader, an action or a route's own render throws, React Router does not crash the app. It renders the nearest matched route's error boundary in place of that route's element, so a failed product page leaves the site chrome intact. A route opts in with ErrorBoundary or the older errorElement. The boundary gets no props, so it reads the thrown value with useRouteError().

import { isRouteErrorResponse, useRouteError } from 'react-router';

function ProductError() {

const error = useRouteError();

if (isRouteErrorResponse(error)) // an expected, HTTP-shaped failure

return <p>{error.status} {error.statusText}: {error.data}</p>;

return <p>Something went wrong: {error.message ?? 'Unknown error'}</p>;

}

Listing 2.155: Distinguishing an expected 404 from a genuine bug Hang it on the route as ErrorBoundary: ProductError, and let the loader throw data('No product with that id', { status: 404 }) when the record is missing. That marks an expected failure — a missing record, a forbidden request — and isRouteErrorResponse() recognizes it, giving you status, statusText and data. Anything else is almost certainly a bug: a plain Error worth logging.Put one boundary on the root route so nothing can reach a blank page, and narrower ones only where a partial failure should stay partial — a comments panel that fails without taking the article with it. A router with none falls back to React Router's built-in page, which prints the stack and is for development only. Warning: A boundary replaces its route's element. On a layout route that renders <Outlet />, the outlet and its children disappear with it — attach the boundary to the child route to keep the chrome.

2.17.11 Streaming Data and useAsyncValue

A loader that awaits everything makes the whole page wait for its slowest query. Streaming fixes that: await the data the page cannot render without and return the rest as an unawaited promise, so React Router renders the shell immediately and fills it in later.

const loader = async ({ params }) => ({

post: await getPost(params.id), // fast: awaited, blocks the render

comments: getComments(params.id), // slow: deliberately not awaited

});

function Post() {

const { post, comments } = useLoaderData();

return <article>

<h2>{post.title}</h2>

<Suspense fallback={<p>Loading comments...</p>}>

<Await resolve={comments} errorElement={<p>Comments failed to load.</p>}>

{(list) => <ul>{list.map((c) => <li key={c.id}>{c.body}</li>)}</ul>}

</Await></Suspense></article>;

}

Listing 2.156: An article that renders before its comments do <Await resolve={promise}> needs a <Suspense> boundary above it. Its children can be a render function receiving the resolved value, as above, or ordinary elements, in which case any component inside reads the value with useAsyncValue().<Await errorElement> catches a rejection, and the element it renders reads the reason with useAsyncError(); with no errorElement, the rejection bubbles to the route's error boundary and useRouteError(). The defer() wrapper that React Router 6.4 required around streamed loader data was removed in version 7: a plain object with a promise-valued property is now enough, on client and server alike.Streaming earns the extra components only when the slow part is genuinely slow and genuinely secondary — comments, recommendations, an analytics chart. If the page is meaningless without the data, await it.

2.17.12 useOutletContext

A layout route often holds something its child routes need — a selected filter, an editing flag, a setter. <Outlet> already has the slot: pass a context prop, and whichever child is rendered reads it back with useOutletContext().

function ReportLayout() {

const [range, setRange] = useState('month');

return <div>

<select value={range} onChange={(e) => setRange(e.target.value)}>

<option value="week">This week</option><option value="month">This month</option></select>

<Outlet context={{ range, setRange }} /></div>;

}

const Revenue = () => <p>Revenue for: {useOutletContext().range}</p>;

Listing 2.157: A layout route sharing state with whichever child is mounted ReportLayout mounts once and owns the state, so switching between child routes changes only what fills the outlet: the selected range survives the navigation, because the parent is a route, mounted as long as the URL stays inside it.Unlike every other hook in this section, useOutletContext() does not require a data router; <Outlet context> works identically inside a declarative <BrowserRouter> tree. In TypeScript, have the layout export a typed hook — export const useRange = () => useOutletContext<RangeContext>(). Keep the value small: it is plain React context (Section 2.5.5).

2.17.13 useBlocker

A half-filled form and a misclicked link are a bad combination. useBlocker(shouldBlock) intercepts in-app navigations so you can ask first; it replaces React Router 5's <Prompt>. The argument is a boolean or a function receiving { currentLocation, nextLocation, historyAction }; the hook returns a blocker whose state is "unblocked", "blocked" or "proceeding", with proceed() and reset() to resolve it.

function DraftEditor() {

const [dirty, setDirty] = useState(false);

const blocker = useBlocker(({ currentLocation, nextLocation }) =>

dirty && currentLocation.pathname !== nextLocation.pathname);

return <div>

<textarea onChange={(e) => setDirty(e.target.value.length > 0)} />

{blocker.state === 'blocked' && <p role="alertdialog">You have unsaved changes.{' '}

<button onClick={() => blocker.proceed()}>Leave anyway</button>{' '}

<button onClick={() => blocker.reset()}>Keep editing</button></p>}</div>;

}

Listing 2.158: Confirming before abandoning unsaved edits Comparing pathnames matters: without it, any query-string change useSearchParams writes counts as a navigation and pops the dialog. Call blocker.reset() when the blocking component unmounts or the form is saved, or a stale blocker can leave the router stuck in "blocked".useBlocker only sees navigations the router controls. Closing the tab, reloading or typing a new address is beforeunload, which browsers restrict to a generic dialog and only after the user has interacted with the page, so a robust editor wires both. For a plain window.confirm() there is unstable_usePrompt({ when, message }), permanently prefixed because browsers disagree about the back button while a synchronous dialog is open.

2.17.14 Route Middleware

Loaders and actions are per route, which leaves the cross-cutting work — authentication, timing, opening a database handle — duplicated across all of them. Middleware is an array on the route object, running parent to child before the handlers and unwinding child to parent afterwards.Each middleware receives ({ request, params, context }, next). await next() runs the rest of the chain and the route's own loader or action: what you do before that call happens on the way down, what you do after happens on the way back up.

import { createContext, redirect, RouterContextProvider } from 'react-router';

const userContext = createContext(null);

const timing = async ({ request }, next) => { const t = performance.now(); await next();

console.log(`${request.url} — ${(performance.now() - t) | 0}ms`); }; // after next()

async function requireUser({ context }, next) {

if (!context.get(userContext)) throw redirect('/login'); // nothing below runs

return next();

}

const routes = [{ path: '/', middleware: [timing], Component: Root, children: [

{ path: 'admin', middleware: [requireUser], loader: adminLoader }] }];

createBrowserRouter(routes, { getContext: () => { // a fresh store per request

const context = new RouterContextProvider();

context.set(userContext, readSession());

return context;

} });

Listing 2.159: Timing and auth middleware sharing one typed context createContext(defaultValue) from react-router is not React's createContext. It creates a typed key for the router's own per-request store, written with context.set(key, value) and read with context.get(key) further down the chain. getContext builds a fresh store per navigation and fetcher call, keeping one user's session out of another's request on the server.This replaces the guard-route pattern of Section 2.16.17: the redirect is thrown before any loader runs, so protected data is never fetched. In framework mode the same arrays are route module exports — middleware runs on the server and returns the Response from next(); clientMiddleware runs in the browser.

2.17.15 Framework Mode

Framework mode is data mode plus a build. The @react-router/dev Vite plugin takes over routing at compile time: routes are declared in app/routes.ts, each route is a module whose exports are the loader, action, component and error boundary, and the plugin generates per-route types, code-splits and renders on the server by default. Start one with npx create-react-router@latest my-app.

import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [index('routes/home.tsx'),

route('products/:id', 'routes/product.tsx')] satisfies RouteConfig;

// app/routes/product.tsx

import type { Route } from './+types/product';

export async function loader({ params }: Route.LoaderArgs) { // runs on the server

return { product: await db.products.find(params.id) };

}

export default function Product({ loaderData }: Route.ComponentProps) {

return <h1>{loaderData.product.name}</h1>; // typed, no hook needed

}

Listing 2.160: app/routes.ts and one route module The generated ./+types/product module is the payoff: params.id, loaderData and actionData are typed from the route path and the loader's return value, with no hand-written interfaces. A route module can also export clientLoader, clientAction, ErrorBoundary, HydrateFallback, headers, links, meta, handle, shouldRevalidate and the middleware arrays of Section 2.17.14.react-router.config.ts switches the app between server rendering (ssr: true, the default), a single-page build (ssr: false) and static prerendering — the row Section 2.16.3's mode table gives framework mode. That makes it a direct alternative to Next.js (Chapter 5) for a React-only front end: it is a router that grew a build, so moving up costs a routes.ts file rather than a rewrite. Tip: Start in data mode. Framework mode adds a server to run, deploy and pay for, and everything in this section works without it. Move up when you need server rendering for SEO, typed route data across a team, or code splitting you would otherwise hand-write with lazy.