FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·107·13 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

The first question to ask about data is not just “how do I fetch it?” It is “what makes this data necessary?” A route loader expresses a specific dependency:

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. If the user navigates somewhere else before this loader finishes, the router can cancel work that is no longer relevant. Passing that signal to fetch is what allows the underlying request to participate in cancellation.

The route component reads the loader result directly:

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

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

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

The useful distinction is ownership: the route owns data needed to enter or render the route, so the component does not need a separate Effect that repeats the same fetch.

Route actions

Actions handle mutations associated with a route. They receive the request produced by a form or router submission, parse its input, call the server, and return either a result or an error that the route can render.

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 the action alongside the loader:

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

The action is not a replacement for server-side validation or authorization. It is the route-level place to coordinate a submission; the server remains the trust boundary.

Router <Form>

React Router’s Form behaves like a form while also connecting submission to the route’s action and navigation lifecycle.

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, the router can revalidate relevant loader data. That means the UI can be rendered from current route data rather than from a hand-maintained sequence of local state updates.

This is a different data model from a manual Effect. An Effect-based fetch usually requires you to coordinate loading, cancellation, errors, and synchronization yourself. A route action gives those concerns a place in the router’s data lifecycle.

Pending navigation

Pending UI should reflect the scope of the work that is actually in progress. For route-level work, inspect 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 when only one panel is changing. Put pending UI at the boundary users actually perceive: a global navigation indicator for route changes, a route skeleton for a page region, or a control-level state for one mutation.

Fetchers

A fetcher can call a loader or action without navigating away from the current route. That makes it a better fit for an interaction that changes one item while the surrounding page remains usable.

Typical cases include:

  • 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 that fetcher instance, not to global navigation. This lets row A update while row B remains interactive, instead of treating every request as a reason to disable the whole page.

Route errors

A loader or action can throw a Response when the route cannot complete normally:

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

The matching route error boundary can turn that failure into useful 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>
  );
}

Place error boundaries where the rest of the application can remain useful. A child detail view may fail with a 404 while the shell and list navigation continue to work. Keeping that hierarchy intentional produces a better failure mode than sending every problem to one root-level message.

Protected UI is not authorization

A client route can redirect unauthenticated users before showing protected UI:

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

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

  return { user };
}

That improves the user flow, but it does not protect an API. A determined caller can bypass a redirect or invoke the endpoint directly. Every server mutation must still establish:

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

The server owns authorization. Hiding a link and authorizing an operation are separate responsibilities.

Revalidation

After an action, route data can revalidate so the UI reflects current server truth. This is especially 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. Neither model is universally better. The practical rule is to choose one owner for a given resource boundary, or deliberately integrate the two.

Router data versus TanStack Query

Use router loaders when data is strongly tied to navigation and the router can own its lifecycle. Route params, loader errors, pending navigation, and action revalidation then stay in one model.

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 in a separate query cache with no integration. Two independent requests and two independent copies of server state create duplicate ownership, which eventually leads to inconsistent freshness and update behavior.

Loader waterfall awareness

Nested route loaders can run efficiently, but route structure still affects when work starts. Avoid a sequence like this:

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. A component-mount sequence often hides a waterfall that matched route data could avoid.

Route lazy loading

Current React Router supports lazy route implementation. This helps split code without hiding the route structure itself from the router.

Use route-level splitting for genuinely large or rarely visited route modules. Do not introduce lazy boundaries merely to make the route configuration look more fragmented.

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 treats navigation as a data transaction rather than as a collection of unrelated component fetches. Conceptually, a navigation follows this path:

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

For a mutation, the lifecycle is:

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

This architecture removes a large amount of component-level fetch coordination. It does not make network behavior disappear; it gives that behavior a route-aware owner.

Parallel loader opportunities

Suppose a parent loader fetches account data and a child loader fetches tasks. Do not artificially make the child wait for the account result unless the task query genuinely needs that result.

A sequential design looks like this:

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

If the requests are independent, they can overlap:

text
account 300ms
tasks 400ms
total ~400ms

Router and framework data systems can begin work from the matched route structure rather than waiting for a component mount sequence. The exact result still depends on the server, network, and dependencies between loaders, so measure rather than assuming every nested loader is parallel.

Request cancellation

A loader receives the request object, including its signal:

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

If the user navigates away before completion, the router can abort the request. The cancellation only reaches the network call if your API wrapper forwards the signal.

A wrapper that silently drops its options defeats cancellation:

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

When debugging cancellation, inspect both boundaries: confirm that the router supplies an aborted signal, then confirm that the helper passes it through to fetch.

Action semantics

An action should treat request input as untrusted, even when the input originated in your own form.

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.',
      },
    };
  }

  ...
}

If this router executes in the browser, the server endpoint must repeat validation and authorization. Client-side validation is useful feedback, not a replacement for the server’s trust-boundary checks.

In a server-capable React Router framework mode, an action may execute server-side depending on the setup. That can change where code runs, but it does not remove the need to reason explicitly about authentication, authorization, and validation.

Redirect after action

After creating a task, an action can return a redirect:

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

That gives the action ownership of the navigation decision. For a form that should remain on the current page, use a fetcher instead when the interaction is local to the current route.

Fetcher concurrency

Multiple rows can each have their own fetcher. Row A can update while row B remains interactive.

Do not use one global check:

jsx
navigation.state !== 'idle'

to disable every button when only one fetcher mutation is pending. That confuses navigation scope with fetcher scope and makes unrelated parts of the interface feel broken.

Optimistic fetcher UI

During a pending submission, fetcher form data can be inspected to render the state the server is expected to produce:

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

This can render a pending prediction before revalidation finishes. It is useful when the intended result is clear, but the server response remains authoritative.

TanStack Query also supports cache-oriented optimistic updates. Do not apply both optimistic models to the same resource without a deliberate ownership strategy; conflicting predictions are difficult to reconcile.

Error boundary hierarchy

An error boundary at the parent route can handle failures for the list route:

text
/tasks

A child boundary can handle a failure for one task:

text
/tasks/:taskId

If the detail route fails with a 404, the application shell and list navigation can remain available. The design question is simple: what working UI should survive this failure?

text
what working UI should survive this failure?

HTTP response semantics

Use meaningful HTTP statuses so both the router and the client can make useful distinctions:

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 these into an HTTP 200 response containing only:

json
{ "ok": false }

Router error APIs can preserve status-specific UX when the response status communicates what actually happened. The application may still choose a consistent error body, but it should not erase the protocol-level meaning.

Revalidation control

Not every action must re-run every loader. Current router APIs support revalidation control, but use that control carefully.

Over-optimizing revalidation too early can produce stale pages and subtle cross-route bugs. Establish correctness first, then reduce unnecessary loader work based on measured traffic and known data dependencies.

Router loader versus query cache integration

Several architectures are valid. The key is to make ownership explicit.

Router owns server data

This is the simple route-centric model:

text
loader → component
action → revalidation

TanStack Query owns server cache

The loader can start or prefetch the query, the component reads the query, and a mutation invalidates it:

text
loader may prefetch query
component reads query
mutation invalidates query

This combines route-aware request start with a long-lived query cache. What should be avoided is two unrelated owners:

text
loader fetches one copy
component useQuery fetches second copy

Without shared cache integration, the two copies can disagree about freshness and mutations.

Pending UI levels

A global navigation bar can show subtle route progress:

jsx
const navigation = useNavigation();

A route-level skeleton can represent pending content for a specific region. A fetcher can show pending state on one button or row. Choose the smallest useful scope. A broad indicator is appropriate for navigation; a local control should not make the whole shell appear unavailable.

Race example

Consider a user who submits an edit and immediately navigates elsewhere. The correct behavior depends on both router cancellation semantics and server design. Ask:

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

There is no universal answer. A save operation that can be repeated safely has different retry and cancellation options from a non-idempotent operation with side effects.

Failure clinic

Loader reads component state

Loaders exist outside component render. Route inputs should come from the request URL, route params, or application infrastructure, not arbitrary local component state. If a loader needs a value, put that value in a route input or choose a different ownership model.

Action performs client-only authorization

The server still must authorize the operation. A client action can improve flow, but it cannot establish permission for an API call.

Fetcher state treated as global navigation

That is the wrong scope. Use the fetcher’s state for the local operation and navigation state for route-level work.

Loader result copied into component state

This creates a second owner unless the copy is intentionally becoming a draft. Mirroring server data just to render it makes synchronization harder.

Deep-dive exercises

  1. Forward the loader AbortSignal through an API helper.
  2. Measure sequential versus parallel loader architecture.
  3. Build action validation with 422-like field state.
  4. Build a 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:

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

Production case study: Router loader prefetching TanStack Query v5

Route-aware loading and a query cache can work together when they have a clear handoff. The router starts the request because navigation identifies the needed resource; TanStack Query retains and shares the result.

Define reusable query options:

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

The loader ensures that data exists in the shared cache:

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

    return null;
  };
}

The component reads that same cache entry:

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

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

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

The loader and component now share one Query cache entry instead of making two unrelated requests. The integration is the important part; merely fetching once in each layer would not provide that benefit.

Important v5 style

queryOptions(...) returns the object-style configuration used by TanStack Query v5. The course never falls back to positional query signatures.

Why combine them?

The router knows about:

text
navigation intent
route params
route error boundaries

The query library knows about:

text
cache
freshness
invalidation
background refetch
shared observers

Together, they can start data early and keep it cached for other observers. Do not combine them automatically, though. For simple route-only data, the Router alone may be the clearer owner.


Additional depth: route actions, forms, and resource routes

Intent buttons in one form

Multiple submit buttons can carry an intent through the same form:

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>

The action branches on the submitted intent:

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 the intent. A client can submit arbitrary values, so the action must not treat the button’s value as trusted input.

Resource/API-like routes

Router framework modes can expose routes that return data without rendering a page, depending on the architecture. Before choosing an action, identify what the request actually represents:

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

Do not force every backend capability into a UI route action. A route action is a useful boundary for route-driven form work, but it is not automatically the right abstraction for every server operation.

Fetcher form versus normal Form

A normal <Form> submits through the navigation lifecycle:

text
submit
→ navigation/revalidation

A fetcher submits without leaving the current route:

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

Use a fetcher for interactions such as:

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

Use a navigation form for workflows such as:

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

Optimistic fetcher intent

Because a fetcher exposes submitted formData, the UI can predict the pending result:

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 so the two layers do not apply conflicting optimistic updates.

Loader security reminder

A browser Data Router loader that calls an API is still client code. The server API must authorize the request.

In server framework mode, a loader may execute server-side; it still needs to authenticate and authorize before accessing privileged data. Always locate the actual trust boundary for the deployment mode you use.

Reader page: /react/lesson/107/react-router-data-apis-loaders-actions-fetchers-errors-and-pending-ui