Topic 13.5
Data Routers: Loaders and Actions
In one line
In data mode you define routes as objects with createBrowserRouter (or createMemoryRouter) and render them with <RouterProvider>; each route can have a loader that fetches its data before it renders, an action that handles its <Form> submissions, and an errorElement for failures, while useNavigation() tells you when a navigation is pending.
Think of it like this
A restaurant where the waiter brings the food before you sit down at your table. In declarative mode you sit at an empty table (the page renders), then order (fetch in an effect), then wait (spinner). In data mode, the moment you say which table (the URL), the kitchen starts cooking (the loader), and you're shown to the table when the food is ready. Handing your order slip to the waiter (submitting a form) goes to the kitchen's order desk (the action), and afterwards every dish on the table is refreshed.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Data router
- A router created with
createBrowserRouter/createMemoryRouterfrom route objects. It can load data and handle forms. - RouterProvider
- The component that renders a data router:
<RouterProvider router={router} />. - Route object
- A plain object describing a route:
path,Component,loader,action,errorElement,children. - Loader
- An async function on a route that returns its data before the route renders. Read it with
useLoaderData(). - Action
- An async function on a route that handles a form submission (POST, PUT, DELETE...). Read its result with
useActionData(). - Form (React Router)
<Form>: like<form>, but submits to a route action without reloading the page.- Revalidation
- Re-running the page's loaders after an action so the screen shows fresh data.
- useNavigation
- A hook telling you if a navigation is
idle,loadingorsubmitting, for pending UI. - errorElement
- What a route shows instead of its element when its loader, action or render throws. Read the error with
useRouteError(). - Framework mode
- React Router plus its Vite plugin: route module files, server rendering, code splitting and generated types.
Step by step
01Create the router once
In a real app, build the router in main.jsx from route objects, outside any component. RouterProvider replaces BrowserRouter and Routes.
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import Root from "./routes/Root.jsx";
import User, { userLoader } from "./routes/User.jsx";
const router = createBrowserRouter([
{
path: "/",
Component: Root,
HydrateFallback: () => <p>Loading...</p>,
children: [
{ path: "users/:userId", loader: userLoader, Component: User },
],
},
]);
createRoot(document.getElementById("root")).render(<RouterProvider router={router} />);02Load data before rendering
The loader receives the params and a request (with request.url and request.signal). Return plain data. The component reads it synchronously.
export async function userLoader({ params, request }) {
const res = await fetch(`/api/users/${params.userId}`, { signal: request.signal });
if (res.status === 404) throw data("User not found", { status: 404 });
if (!res.ok) throw new Error("Could not load the user");
return res.json();
}
export default function User() {
const user = useLoaderData();
return <h1>{user.name}</h1>;
}03What happens on a click
The router matches first, loads second, renders third. The old screen stays until the data arrives, so there's no flash of an empty page. Loaders of all matched routes run at the same time, which avoids the parent-then-child waterfall you get with fetch-in-effect.
04Write with Form and an action
Fields need a name. The action reads them from request.formData(), validates, writes, and returns errors or a redirect. Loaders re-run afterwards.
export async function newProjectAction({ request }) {
const form = await request.formData();
const title = String(form.get("title") ?? "").trim();
if (!title) return { error: "Title is required" };
const project = await api.createProject({ title });
return redirect(`/projects/${project.id}`);
}
export default function NewProject() {
const result = useActionData();
const navigation = useNavigation();
return (
<Form method="post">
<input name="title" />
{result?.error && <p role="alert">{result.error}</p>}
<button disabled={navigation.state === "submitting"}>Create</button>
</Form>
);
}05Handle failures per route
Give routes an errorElement. Expected problems are thrown as responses with a status; unexpected ones are plain errors. Errors bubble up to the nearest route that has an error element, and parents above it keep rendering.
export function RouteError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <h1>{error.status}: {error.data}</h1>;
}
return <h1>Something went wrong</h1>;
}06Framework mode: the same ideas in files
In framework mode the route objects are generated from app/routes.ts, and each file exports its loader, action and component. Loaders run on the server.
import { type RouteConfig, index, route, layout } from "@react-router/dev/routes";
export default [
index("./home.tsx"),
layout("./dashboard/layout.tsx", [
route("products/:pid", "./product.tsx"),
]),
] satisfies RouteConfig;Try it yourself
- 1
Make the loader slower
In the first example, change the fake API delay to 1000 ms and
waitto 3000. Predict how long "Loading the app..." shows and how long Asha stays dimmed. Then removeHydrateFallbackand look for the warning in the console. - 2
Skip the redirect
In the todos example, replace
return redirect(...)withreturn { ok: true };. Predict: does the list still update? Does the address bar change? Why? - 3
Bubble the error up
In the recipes example, move
errorElement: <RecipeError />from the recipe route to the root route. Predict what disappears when you visit/recipes/9. Errors go to the nearest error element above the failing route.
Code & diagrams
The first loader runs **before** `Root` renders at all (the `HydrateFallback` shows meanwhile). On the click, `Root` re-renders with `loading` while the URL still says `/users/1` and Asha stays (dimmed) on screen; only when Ravi's data arrives does the page switch.
Preview
Console
Nothing logged yet.Expected console output
loader: fetching user 1
Root: idle /users/1
loader: fetching user 2
Root: loading /users/1
Root: idle /users/2The empty submit runs the action, which returns an error object; the loader still re-runs (revalidation). The second submit saves and redirects; the loader runs again so the list shows the new todo. No `useState` for the list and no manual refetch. The input is uncontrolled: `Form` reads it by `name`.
Preview
Console
Nothing logged yet.Expected console output
loader: reading 1 todos
action: POST ""
loader: reading 1 todos
error shown: Please type a title
action: POST "Write an action"
loader: reading 2 todos
list: Learn routes, Write an action | url: /todos?added=Write%20an%20actionThe error element replaces only the recipe route; the address bar and nav (in `Root`) keep working, and navigating to a good recipe clears the error. A `data(..., { status: 404 })` throw is recognised by `isRouteErrorResponse`; a plain `Error` is not.
Preview
Console
Nothing logged yet.Expected console output
/recipes/1 shows: Pancakes
response error: 404 No recipe 9
/recipes/9 shows: 404: No recipe 9
unexpected error: Database is down
/recipes/3 shows: Something went wrong: Database is down
/recipes/1 shows: Pancakesimport type { Route } from "./+types/product";
import { Form, data } from "react-router";
// Runs on the server for the first request, and for client navigations
export async function loader({ params }: Route.LoaderArgs) {
const product = await db.product.find(params.pid);
if (!product) throw data("Not found", { status: 404 });
return { product };
}
export async function action({ request, params }: Route.ActionArgs) {
const form = await request.formData();
await db.review.create({ productId: params.pid, text: String(form.get("text")) });
return { ok: true };
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <p>Sorry, something went wrong.</p>;
}
export default function Product({ loaderData }: Route.ComponentProps) {
return (
<>
<title>{loaderData.product.name}</title>
<h1>{loaderData.product.name}</h1>
<Form method="post"><textarea name="text" /><button>Review</button></Form>
</>
);
}$ npx create-react-router@latest my-app
$ cd my-app
$ npm i
$ npm run dev
➜ Local: http://localhost:5173/Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
No HydrateFallback for an initial loader
Remove HydrateFallback from the root route in the first example.
Break #2
Calling useLoaderData in declarative mode
Use useLoaderData() in a component rendered by <MemoryRouter><Routes>...</Routes></MemoryRouter> (or BrowserRouter).
Break #3
Form fields without a name
Remove name="title" from the input in the todos example and submit "Write an action".
Myth vs fact
Myth
Loaders replace TanStack Query.
Fact
Loaders decide when data loads (per navigation, in parallel, before render). Many apps call TanStack Query's cache from loaders (queryClient.ensureQueryData) to get caching and background refresh too (Topic 14.6).
Myth
You must use useEffect to refetch after a form submit.
Fact
After an action, the router re-runs the page's loaders automatically (revalidation).
Myth
Data mode needs a server.
Fact
Data mode runs entirely in the browser: loaders call your API with fetch. Only framework mode adds server-side loaders and rendering.
Myth
React Router's <Form> is the same as React 19's <form action={fn}>.
Fact
Both submit without a reload, but <Form> sends the data to a route action, creates a navigation (or not with navigate={false}) and revalidates loaders. React 19 form actions (Phase 19) call any function and know nothing about routes.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Loaders for all matched routes start in parallel as soon as the navigation starts, before any component code runs. Combined with
lazyroute modules (Topic 13.6), this is "render-as-you-fetch" (Topic 14.1): code and data download together instead of in a waterfall. - ▸
If a loader returns a promise inside an object (
return { reviews: fetchReviews() }without awaiting), the page can render immediately and show that part later with<Await>inside<Suspense>, or with React 19'suse(). The olddefer()helper was removed in v7 in favour of returning raw promises. - ▸
useFetcher()submits to an action or calls a loader without navigating (no new history entry, its ownstate). It's the tool for like buttons, inline edits and "add to cart" where the user should stay on the page, and for optimistic UI viafetcher.formData. - ▸
Version 8 always enables route middleware (
middleware: [fn]on route objects), which runs before loaders and actions and can share data through a typedcontext. It's the place for cross-cutting work like authentication and logging. Version 8 also always passes the rawrequestto loaders and actions.
Remember this
- 1
A data router is created outside React with
createBrowserRouter(routes)(real apps),createHashRouterorcreateMemoryRouter(routes, { initialEntries })(tests and this preview), and rendered with<RouterProvider router={router} />.routesis an array of route objects:{ path, Component, loader, action, errorElement, children }. UseComponent: User(the component itself) orelement: <User />; nesting, index routes and layout routes work exactly as in Topic 13.4, withchildrenarrays andindex: true. Create the router once at module level, never inside a component or in state: it owns the history and all loaded data. - 2
A loader is an async function on a route that returns the data the route needs:
loader: async ({ params, request }) => fetchUser(params.userId). When the user navigates, the router first matches the URL, then calls the loaders of every matched route in parallel, waits for them, and only then renders the new page. Inside the component, **useLoaderData()** returns what the loader returned, already available on the first render: nouseEffect, noisLoadingstate, no empty first frame.request.signalis anAbortSignalthat is aborted if the user navigates away first, so pass it tofetchto cancel stale requests (the race you handled by hand in Topic 7.6). - 3
**Pending UI with
useNavigation(). While loaders run, the old** page stays on screen and the URL hasn't changed yet.useNavigation()returns an object whosestateis"idle","loading"(loaders running for a new location) or"submitting"(an action running), pluslocation(where you're going) andformDataduring submissions. Use it for a global progress bar, dimming the old page, or disabling a submit button.NavLink'sisPendingis true for the link being loaded. On the very first load there is no old page, so a route can provide **HydrateFallback** (orhydrateFallbackElement) to show while the initial loaders run; without one, React Router warnsNoHydrateFallbackelement provided to render during initial hydration. - 4
An action handles writes.
<Form method="post">from React Router looks like an HTML<form>, but on submit it doesn't reload the page: it collects the fields intoFormDataand calls the action of the route it is rendered in (or the route given by itsactionprop). The action receives{ request, params };await request.formData()gives the fields by theirname. An action can return data (read with **useActionData()**, perfect for validation errors) orredirect("/somewhere"). After any action that doesn't throw, the router revalidates: it re-runs the loaders on the page so the UI shows fresh data, with no manual refetching. A<Form method="get">instead turns its fields into search params and navigates, like a search form. - 5
Errors. If a loader or action throws (or a component throws while rendering), the router renders the nearest route's **
errorElement** (orErrorBoundarycomponent) in place of that route's element, keeping parent layouts working. Inside it, **useRouteError()** returns what was thrown. Throwdata("Not found", { status: 404 })(or aResponse) for expected errors like a missing record; **isRouteErrorResponse(error)** istruefor those and givesstatusanddata. Anything else (a bug, a network failure) arrives as a normalError. Without anyerrorElement, React Router shows its default "Unexpected Application Error!" screen. Loaders can alsothrow redirect("/login")to send the user elsewhere, which Topic 13.6 uses for protected routes. - 6
Framework mode in brief. React Router's framework mode (the successor of Remix) builds on data mode with a Vite plugin. Routes are listed in
app/routes.tswith helpers likeroute("products/:pid", "./product.tsx"),index(),layout()andprefix()(or file names via@react-router/fs-routes). Each route module file exportsloader,action,clientLoader,ErrorBoundary,HydrateFallback,metaand a default component that receivesloaderDataas a prop, with types generated for you (./+types/product). Loaders run on the server (so they can read a database directly), pages are server-rendered or pre-rendered, and code is split per route automatically. Start one withnpx create-react-router@latest. Phase 20 compares this with Next.js and Server Components.
Explain it without notes
What problem do loaders solve compared with fetching in useEffect?
Walk through what happens when a <Form method="post"> is submitted in a data router.
How do you show pending UI during navigations, and why does the old page stay visible?
How do errors work in data routers, and how do you tell a 404 from a crash?
Practice
Create a memory data router with a /posts route whose loader returns three post titles after 200 ms, and render them with useLoaderData.
Add a newsletter <Form method="post"> with an email field to a route; the action returns { error } when the email has no @, and { ok: true } otherwise. Show the right message.
Make a /posts/:id loader throw a 404 response for unknown ids and show Post not found from an errorElement.
Trade-offs
- ↔
Data mode removes loading boilerplate and waterfalls, but routes must be defined up front as objects, and data loading is tied to navigation. Data that changes without navigation (live updates, polling) still needs another tool.
- ↔
Keeping the old page visible during loading feels smooth for fast loaders, but slow ones feel frozen unless you add pending UI. Always show something when
navigation.state !== "idle". - ↔
Framework mode adds server rendering and type safety, but brings a build plugin, a server runtime and conventions you must follow. Data mode keeps you in a plain Vite SPA.
Done when you can
I can create a data router with route objects and render it with
RouterProvider.I can write a loader with params and
request.signal, and read it withuseLoaderData.I can handle a form with
<Form>, an action,useActionDataandredirect.I can show pending UI with
useNavigationand aHydrateFallback.I can add
errorElements and tell 404 responses from crashes.I can describe what framework mode adds (routes.ts, route modules, server loaders).