Module: React and Ecosystem
React and Ecosystem·107·7 MIN READ

107: React Router Data APIs — Loaders, Actions, Fetchers, Errors, and Pending UI

TOPICS COVERED: React Router Data APIs — Loaders, Actions, Fetchers, Errors, and Pending UI

Learning objectives

You will learn to:

  • load route data with loader;
  • mutate route data with action;
  • use request cancellation provided by the router;
  • model pending navigation and mutation UI;
  • use route error boundaries;
  • submit without navigation through fetchers;
  • understand revalidation;
  • separate UI protection from server authorization;
  • decide when route data APIs or TanStack Query should own a resource.

Route data is tied to navigation

A route loader expresses:

This data is required for this route.

jsx
const router = createBrowserRouter([
  {
    path: '/tasks',
    Component: TaskListPage,
    loader: async ({ request }) => {
      const response =
        await fetch('/api/tasks', {
          signal:
            request.signal,
        });

      if (!response.ok) {
        throw new Response(
          'Could not load tasks',
          {
            status:
              response.status,
          },
        );
      }

      return response.json();
    },
  },
]);

React Router supplies an AbortSignal on the request. When navigation makes the loader irrelevant, the request can be cancelled.

Read the data:

jsx
import {
  useLoaderData,
} from 'react-router';

function TaskListPage() {
  const { tasks } =
    useLoaderData();

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          {task.title}
        </li>
      ))}
    </ul>
  );
}

Route actions

Actions handle data mutations associated with routes.

jsx
async function createTaskAction({
  request,
}) {
  const formData =
    await request.formData();

  const title =
    String(
      formData.get('title')
      ?? '',
    ).trim();

  const response =
    await fetch('/api/tasks', {
      method: 'POST',
      headers: {
        'Content-Type':
          'application/json',
      },
      body: JSON.stringify({
        title,
      }),
    });

  if (response.status === 422) {
    return {
      errors:
        await response.json(),
    };
  }

  if (!response.ok) {
    throw new Response(
      'Could not create task',
      {
        status:
          response.status,
      },
    );
  }

  return response.json();
}

Attach:

jsx
{
  path: '/tasks',
  Component: TaskListPage,
  loader: loadTasks,
  action: createTaskAction,
}

Router <Form>

jsx
import {
  Form,
  useActionData,
} from 'react-router';

function NewTaskForm() {
  const result =
    useActionData();

  return (
    <Form method="post">
      <label htmlFor="title">
        Title
      </label>

      <input
        id="title"
        name="title"
      />

      {result?.errors?.title && (
        <p role="alert">
          {
            result.errors
              .title
          }
        </p>
      )}

      <button>
        Create
      </button>
    </Form>
  );
}

After an action completes, relevant loader data can revalidate.

This is a different data model from a manual Effect.

Pending navigation

Use navigation state:

jsx
import {
  useNavigation,
} from 'react-router';

function AppShell() {
  const navigation =
    useNavigation();

  const busy =
    navigation.state
    !== 'idle';

  return (
    <div
      aria-busy={busy}
    >
      {busy && (
        <p role="status">
          Updating page…
        </p>
      )}

      <Outlet />
    </div>
  );
}

Do not block the entire application if only one panel is changing.

Choose pending UI at the boundary users actually perceive.

Fetchers

A fetcher can call a loader/action without navigating away.

Use cases:

  • inline toggle;
  • favorite button;
  • delete row;
  • autosave;
  • background refresh.

Conceptual example:

jsx
function TaskToggle({
  task,
}) {
  const fetcher =
    useFetcher();

  const pending =
    fetcher.state
    !== 'idle';

  return (
    <fetcher.Form
      method="post"
      action={
        `/tasks/${task.id}/toggle`
      }
    >
      <button
        disabled={pending}
      >
        {pending
          ? 'Updating…'
          : task.completed
            ? 'Reopen'
            : 'Complete'}
      </button>
    </fetcher.Form>
  );
}

Fetcher state belongs to the fetcher instance, not the global navigation.

Route errors

A loader or action can throw a Response:

jsx
throw new Response(
  'Task not found',
  { status: 404 },
);

Route error UI:

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

function TaskErrorBoundary() {
  const error =
    useRouteError();

  if (
    isRouteErrorResponse(
      error,
    )
  ) {
    return (
      <section role="alert">
        <h1>
          {error.status}
        </h1>
        <p>
          {
            error.statusText
          }
        </p>
      </section>
    );
  }

  return (
    <section role="alert">
      <h1>
        Something went
        wrong
      </h1>
    </section>
  );
}

Put error boundaries where the rest of the application can remain useful.

Protected UI is not authorization

A client route can redirect unauthenticated users:

jsx
async function protectedLoader({
  request,
}) {
  const user =
    await getSessionUser();

  if (!user) {
    throw redirect(
      `/login?returnTo=${
        new URL(
          request.url,
        ).pathname
      }`,
    );
  }

  return { user };
}

But a client redirect or hidden button cannot secure an API.

Every server mutation must still authorize:

  • who is the user?
  • may they access this record?
  • may they perform this action?

The server owns authorization.

Revalidation

After an action, route data can revalidate to reflect current server truth.

That is useful when:

  • the same mutation affects several route projections;
  • server normalization matters;
  • a simple reload model is acceptable.

TanStack Query uses explicit cache invalidation and update semantics instead. Both are valid; choose one owner for a given resource boundary.

Router data versus TanStack Query

Use router loaders when data is strongly tied to navigation and the router can own its lifecycle.

Use TanStack Query when you need a rich long-lived client cache across:

  • multiple routes;
  • polling;
  • background refetch;
  • prefetch;
  • invalidation;
  • pagination;
  • mutation coordination.

Avoid loading the same resource in a loader and a separate query cache with no integration. That creates duplicate ownership.

Loader waterfall awareness

Nested route loaders can run efficiently, but application structure still matters.

Avoid:

text
load user
then render
then child Effect loads team
then child Effect loads tasks

when those requests could be owned by the route/data architecture and started earlier.

Route lazy loading

Current React Router supports lazy route implementation.

This helps split code without hiding the route structure itself.

Use route-level splitting for genuinely large or rare route modules.

Common mistakes

  • fetching route data in useEffect despite already having a loader;
  • treating useNavigation pending state as mutation state for every fetcher;
  • hiding unauthorized links but leaving the API unprotected;
  • using both route loader state and local mirrored copies;
  • returning every server error as HTTP 200;
  • losing validation field values after action failure;
  • putting all errors at the root boundary.

Exercises

  1. Load tasks with a route loader using request.signal.
  2. Create a task through a route action.
  3. Add a fetcher-based inline toggle.
  4. Add a route error boundary for 404 and 500.
  5. Add pending navigation UI without replacing the whole shell.
  6. Explain whether tasks in your app belong to loaders or TanStack Query.

Exit questions

  1. What lifecycle does a loader own?
  2. What happens after a route action succeeds?
  3. What problem does a fetcher solve?
  4. Why is protected UI not authorization?
  5. What is revalidation?
  6. When should route data APIs own a resource instead of TanStack Query?

Official references


Deep dive: loader/action lifecycle and concurrency

A Data Router understands navigation as a data transaction.

Conceptually:

text
user navigates
→ match routes
→ run relevant loaders
→ render route tree with results

For mutation:

text
user submits action
→ action runs
→ relevant loader data revalidates
→ UI renders new route data

This architecture avoids many component-level fetch Effects.

Parallel loader opportunities

Suppose parent loader fetches account and child loader fetches tasks.

Do not artificially make child wait for account unless task query truly requires account result.

A waterfall:

text
request account 300ms
then request tasks 400ms
total 700ms+

Parallel:

text
account 300ms
tasks 400ms
total ~400ms

Router/framework data systems can begin work based on matched routes rather than component mount sequence.

Request cancellation

Loader receives:

jsx
async function loader({ request }) {
  return fetch('/api/tasks', {
    signal: request.signal,
  });
}

If user navigates away before completion, router can abort.

Ensure your API wrapper forwards signal.

A wrapper that ignores signal defeats cancellation:

jsx
function request(url, options) {
  return fetch(url); // options lost
}

Action semantics

An action should treat request input as untrusted.

jsx
async function action({ request, params }) {
  const formData = await request.formData();

  const title = String(formData.get('title') ?? '').trim();

  if (title.length < 3) {
    return {
      errors: {
        title: 'Use at least 3 characters.',
      },
    };
  }

  ...
}

Server endpoint still repeats validation and authorization if this router executes in browser.

If using a server-capable React Router framework mode, action may execute server-side depending on setup, but trust boundaries still need explicit reasoning.

Redirect after action

After create:

jsx
return redirect(`/tasks/${task.id}`);

This gives navigation semantic ownership to the action.

For a form that should stay on same page, a fetcher may be better.

Fetcher concurrency

Multiple rows can each have a fetcher.

This lets row A update while row B remains interactive.

Do not use one global:

jsx
navigation.state !== 'idle'

to disable every button if only one fetcher mutation is pending.

Optimistic fetcher UI

Fetcher form data can be inspected during pending state.

Conceptually:

jsx
const completed =
  fetcher.formData
    ? fetcher.formData.get('completed') === 'true'
    : task.completed;

This can render pending prediction before revalidation finishes.

TanStack Query later offers a cache-oriented optimistic model. Do not mix both optimisms for same resource without a reason.

Error boundary hierarchy

Parent:

text
/tasks

boundary can handle list route failures.

Child:

text
/tasks/:taskId

boundary can handle one task.

If details fails 404, app shell/list navigation can remain.

Think:

text
what working UI should survive this failure?

HTTP response semantics

Use meaningful statuses.

text
400 malformed
401 unauthenticated
403 forbidden
404 not found
409 conflict
422 validation
429 rate limited
500 unexpected server failure

Do not convert all of them to:

json
{ "ok": false }

with HTTP 200.

Router error APIs can preserve status-specific UX.

Revalidation control

Not every action must re-run every loader.

Current router APIs support revalidation control; use carefully.

Over-optimizing revalidation too early can produce stale pages.

First establish correctness, then reduce unnecessary loader work based on measured traffic.

Router loader versus query cache integration

There are several valid architectures.

Router owns server data

Simple.

text
loader → component
action → revalidation

TanStack Query owns server cache

text
loader may prefetch query
component reads query
mutation invalidates query

This can combine route-aware request start with long-lived query cache.

Avoid:

text
loader fetches one copy
component useQuery fetches second copy

without shared cache integration.

Pending UI levels

Global navigation bar:

jsx
const navigation = useNavigation();

can show subtle route progress.

Route-level skeleton can show specific pending content.

Fetcher can show button-row pending state.

Choose the smallest useful scope.

Race example

User submits edit then immediately navigates elsewhere.

Questions:

  • should request continue?
  • can it safely retry?
  • does navigation abort it?
  • is mutation idempotent?
  • should UI preserve pending notification?

Router cancellation semantics and server design both matter.

Failure clinic

Loader reads component state

Loaders exist outside component render. Route inputs should come from request URL, params, or application infrastructure—not arbitrary local state.

Action performs client-only authorization

Server still must authorize.

Fetcher state treated as global navigation

Wrong scope.

Loader result copied into component state

Creates second owner unless intentionally creating draft.

Exercises

  1. Forward loader AbortSignal through API helper.
  2. Measure sequential versus parallel loader architecture.
  3. Build action validation with 422-like field state.
  4. Build fetcher inline toggle with row-specific pending UI.
  5. Create nested route error boundaries.
  6. Integrate a loader that prefetches a TanStack Query rather than separately fetching.
  7. Document cancellation semantics for save-then-navigate.

Mastery check

Explain:

  • loader/action transaction;
  • cancellation;
  • fetcher scope;
  • revalidation;
  • route error hierarchy;
  • how router and TanStack Query can cooperate without duplicate data ownership.

Production case study: Router loader prefetching TanStack Query v5

You can combine route-aware loading with a query cache.

Reusable query options:

jsx
function taskQueryOptions(taskId) {
  return queryOptions({
    queryKey: ['task', taskId],
    queryFn: ({ signal }) => getTask({ taskId, signal }),
    staleTime: 60_000,
  });
}

Loader:

jsx
function taskLoader(queryClient) {
  return async ({ params }) => {
    await queryClient.ensureQueryData(
      taskQueryOptions(params.taskId),
    );

    return null;
  };
}

Component:

jsx
function TaskDetailsPage() {
  const { taskId } = useParams();

  const query = useQuery(
    taskQueryOptions(taskId),
  );

  return <TaskDetails task={query.data.task} />;
}

Now the loader and component share one Query cache entry rather than making two unrelated requests.

Important v5 style

queryOptions(...) returns the same object-style configuration used by TanStack Query v5.

The course never falls back to positional query signatures.

Why combine them?

Router knows:

text
navigation intent
route params
route error boundaries

Query knows:

text
cache
freshness
invalidation
background refetch
shared observers

Together they can start data early and keep it cached.

Do not combine them automatically. For simple route-only data, Router alone may be enough.


Additional depth: route actions, forms, and resource routes

Intent buttons in one form

Multiple submit buttons can carry intent:

jsx
<Form method="post">
  <input name="title" />

  <button name="intent" value="save">
    Save
  </button>

  <button name="intent" value="save-and-close">
    Save and close
  </button>
</Form>

Action:

jsx
const formData = await request.formData();
const intent = formData.get('intent');

switch (intent) {
  case 'save':
    ...
  case 'save-and-close':
    ...
  default:
    return { error: 'Unknown action' };
}

Validate intent. Do not trust arbitrary client values.

Resource/API-like routes

Router framework modes can expose routes that return data without rendering a page, depending architecture.

Understand whether a request belongs to:

text
route page data
resource/API endpoint
server function
external API service

Do not force every backend capability into a UI route action.

Fetcher form versus normal Form

Normal <Form>:

text
submit
→ navigation/revalidation

Fetcher:

text
submit/load
→ stay on current route
→ local fetcher state
→ revalidation as configured

Examples for fetcher:

  • inline favorite;
  • row toggle;
  • background delete;
  • autocomplete.

Examples for navigation form:

  • search page;
  • login redirect;
  • create then go to detail.

Optimistic fetcher intent

Because fetcher exposes submitted formData, UI can predict:

jsx
const optimisticCompleted =
  fetcher.formData
    ? fetcher.formData.get('completed') === 'true'
    : task.completed;

This is a router-owned optimistic pattern.

If TanStack Query owns the same task resource, prefer one mutation owner to avoid conflicting optimistic layers.

Loader security reminder

A browser Data Router loader calling an API is still client code.

Server API must authorize.

In server framework mode, loader may execute server-side; still authenticate/authorize there before privileged data access.

Always locate the actual trust boundary for the deployment mode you use.