MENU
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>;
}
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} />);
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
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}`);
}
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>
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>;
}
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>;
}
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>;
}
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>;
}
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>
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>;
}
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>;
}
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>;
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>;
}
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;
} });
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
}