107: 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.
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:
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.
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:
{
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.
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:
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:
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:
throw new Response(
'Task not found',
{ status: 404 },
);
The matching route error boundary can turn that failure into useful UI:
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:
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:
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
useEffectdespite already having a loader; - treating
useNavigationpending 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
- Load tasks with a route loader using
request.signal. - Create a task through a route action.
- Add a fetcher-based inline toggle.
- Add a route error boundary for 404 and 500.
- Add pending navigation UI without replacing the whole shell.
- Explain whether tasks in your app belong to loaders or TanStack Query.
Exit questions
- What lifecycle does a loader own?
- What happens after a route action succeeds?
- What problem does a fetcher solve?
- Why is protected UI not authorization?
- What is revalidation?
- When should route data APIs own a resource instead of TanStack Query?
Official references
- https://reactrouter.com/start/data/data-loading
- https://reactrouter.com/start/data/actions
- https://reactrouter.com/start/data/pending-ui
- https://reactrouter.com/start/data/fetchers
- https://reactrouter.com/start/data/route-object
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:
user navigates
→ match routes
→ run relevant loaders
→ render route tree with results
For a mutation, the lifecycle is:
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:
request account 300ms
then request tasks 400ms
total 700ms+
If the requests are independent, they can overlap:
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:
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:
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.
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:
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:
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:
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:
/tasks
A child boundary can handle a failure for one task:
/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?
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:
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:
{ "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:
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:
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:
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:
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
- Forward the loader
AbortSignalthrough an API helper. - Measure sequential versus parallel loader architecture.
- Build action validation with 422-like field state.
- Build a fetcher inline toggle with row-specific pending UI.
- Create nested route error boundaries.
- Integrate a loader that prefetches a TanStack Query rather than separately fetching.
- 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:
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:
function taskLoader(queryClient) {
return async ({ params }) => {
await queryClient.ensureQueryData(
taskQueryOptions(params.taskId),
);
return null;
};
}
The component reads that same cache entry:
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:
navigation intent
route params
route error boundaries
The query library knows about:
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:
<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:
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:
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:
submit
→ navigation/revalidation
A fetcher submits without leaving the current route:
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:
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.
