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
A route loader expresses:
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. When navigation makes the loader irrelevant, the request can be cancelled.
Read the data:
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.
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:
{
path: '/tasks',
Component: TaskListPage,
loader: loadTasks,
action: createTaskAction,
}
Router <Form>
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:
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:
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:
throw new Response(
'Task not found',
{ status: 404 },
);
Route error 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>
);
}
Put error boundaries where the rest of the application can remain useful.
Protected UI is not authorization
A client route can redirect unauthenticated users:
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:
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
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 understands navigation as a data transaction.
Conceptually:
user navigates → match routes → run relevant loaders → render route tree with results
For mutation:
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:
request account 300ms then request tasks 400ms total 700ms+
Parallel:
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:
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:
function request(url, options) {
return fetch(url); // options lost
}
Action semantics
An action should treat request input as untrusted.
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:
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:
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:
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:
/tasks
boundary can handle list route failures.
Child:
/tasks/:taskId
boundary can handle one task.
If details fails 404, app shell/list navigation can remain.
Think:
what working UI should survive this failure?
HTTP response semantics
Use meaningful statuses.
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:
{ "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.
loader → component action → revalidation
TanStack Query owns server cache
loader may prefetch query component reads query mutation invalidates query
This can combine route-aware request start with long-lived query cache.
Avoid:
loader fetches one copy component useQuery fetches second copy
without shared cache integration.
Pending UI levels
Global navigation bar:
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
- Forward loader AbortSignal through API helper.
- Measure sequential versus parallel loader architecture.
- Build action validation with 422-like field state.
- Build 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:
- 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:
function taskQueryOptions(taskId) {
return queryOptions({
queryKey: ['task', taskId],
queryFn: ({ signal }) => getTask({ taskId, signal }),
staleTime: 60_000,
});
}
Loader:
function taskLoader(queryClient) {
return async ({ params }) => {
await queryClient.ensureQueryData(
taskQueryOptions(params.taskId),
);
return null;
};
}
Component:
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:
navigation intent route params route error boundaries
Query knows:
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:
<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:
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:
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>:
submit → navigation/revalidation
Fetcher:
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:
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.
