Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~35 min· topic 5 of 6

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/createMemoryRouter from 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, loading or submitting, 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.

src/main.jsxwhole filejsx
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.

src/routes/User.jsxwhole filejsx
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.

What happens on a clickdiagram
Rendering diagram…

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.

src/routes/NewProject.jsxwhole filejsx
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>
  );
}
Write with Form and an actiondiagram
Rendering diagram…

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.

RouteError.jsxwhole filejsx
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.

app/routes.tswhole filets
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. 1

    Make the loader slower

    In the first example, change the fake API delay to 1000 ms and wait to 3000. Predict how long "Loading the app..." shows and how long Asha stays dimmed. Then remove HydrateFallback and look for the warning in the console.

  2. 2

    Skip the redirect

    In the todos example, replace return redirect(...) with return { ok: true };. Predict: does the list still update? Does the address bar change? Why?

  3. 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

A loader and pending UI New tab

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

Press Run to see the page.

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/2
An action with Form, validation, redirect and revalidation New tab

The 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

Press Run to see the page.

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%20action
errorElement: expected 404s and unexpected failures New tab

The 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

Press Run to see the page.

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: Pancakes
A framework-mode route module (app/product.tsx)tsx
import 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>
    </>
  );
}
Starting a framework-mode appbash
$ 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.

terminal
$ Console
── what you'll see ──
No `HydrateFallback` element provided to render during initial hydration

Break #2

Calling useLoaderData in declarative mode

Use useLoaderData() in a component rendered by <MemoryRouter><Routes>...</Routes></MemoryRouter> (or BrowserRouter).

terminal
$ Console
── what you'll see ──
Uncaught Error: useLoaderData must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.

Break #3

Form fields without a name

Remove name="title" from the input in the todos example and submit "Write an action".

terminal
$ Console
── what you'll see ──
action: POST ""
(page) Please type a title

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 lazy route 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's use(). The old defer() 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 own state). 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 via fetcher.formData.

  • ▸

    Version 8 always enables route middleware (middleware: [fn] on route objects), which runs before loaders and actions and can share data through a typed context. It's the place for cross-cutting work like authentication and logging. Version 8 also always passes the raw request to loaders and actions.

Remember this

  1. 1

    A data router is created outside React with createBrowserRouter(routes) (real apps), createHashRouter or createMemoryRouter(routes, { initialEntries }) (tests and this preview), and rendered with <RouterProvider router={router} />. routes is an array of route objects: { path, Component, loader, action, errorElement, children }. Use Component: User (the component itself) or element: <User />; nesting, index routes and layout routes work exactly as in Topic 13.4, with children arrays and index: true. Create the router once at module level, never inside a component or in state: it owns the history and all loaded data.

  2. 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: no useEffect, no isLoading state, no empty first frame. request.signal is an AbortSignal that is aborted if the user navigates away first, so pass it to fetch to cancel stale requests (the race you handled by hand in Topic 7.6).

  3. 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 whose state is "idle", "loading" (loaders running for a new location) or "submitting" (an action running), plus location (where you're going) and formData during submissions. Use it for a global progress bar, dimming the old page, or disabling a submit button. NavLink's isPending is true for the link being loaded. On the very first load there is no old page, so a route can provide **HydrateFallback** (or hydrateFallbackElement) to show while the initial loaders run; without one, React Router warns No HydrateFallback element provided to render during initial hydration.

  4. 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 into FormData and calls the action of the route it is rendered in (or the route given by its action prop). The action receives { request, params }; await request.formData() gives the fields by their name. An action can return data (read with **useActionData()**, perfect for validation errors) or redirect("/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. 5

    Errors. If a loader or action throws (or a component throws while rendering), the router renders the nearest route's **errorElement** (or ErrorBoundary component) in place of that route's element, keeping parent layouts working. Inside it, **useRouteError()** returns what was thrown. Throw data("Not found", { status: 404 }) (or a Response) for expected errors like a missing record; **isRouteErrorResponse(error)** is true for those and gives status and data. Anything else (a bug, a network failure) arrives as a normal Error. Without any errorElement, React Router shows its default "Unexpected Application Error!" screen. Loaders can also throw redirect("/login") to send the user elsewhere, which Topic 13.6 uses for protected routes.

  6. 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.ts with helpers like route("products/:pid", "./product.tsx"), index(), layout() and prefix() (or file names via @react-router/fs-routes). Each route module file exports loader, action, clientLoader, ErrorBoundary, HydrateFallback, meta and a default component that receives loaderData as 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 with npx create-react-router@latest. Phase 20 compares this with Next.js and Server Components.

Explain it without notes

01

What problem do loaders solve compared with fetching in useEffect?

02

Walk through what happens when a <Form method="post"> is submitted in a data router.

03

How do you show pending UI during navigations, and why does the old page stay visible?

04

How do errors work in data routers, and how do you tell a 404 from a crash?

Practice

01

Create a memory data router with a /posts route whose loader returns three post titles after 200 ms, and render them with useLoaderData.

02

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.

03

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 with useLoaderData.

  • I can handle a form with <Form>, an action, useActionData and redirect.

  • I can show pending UI with useNavigation and a HydrateFallback.

  • I can add errorElements and tell 404 responses from crashes.

  • I can describe what framework mode adds (routes.ts, route modules, server loaders).