Loaders, Actions & Data APIs
Most React tutorials fetch data inside useEffect: render the component, kick off a request, show a spinner, then re-render when the data arrives. The data router flips this around. With createBrowserRouter, each route can declare a loader that runs before its component renders, so the UI never mounts in an empty, loading state. The router also adds action handlers for mutations, useFetcher for non-navigation requests, and deferred data for streaming slow parts of a page. Together these turn the URL into the single source of truth for both what is shown and what data backs it.
Why this beats fetching in effects
Fetching in an effect is convenient but has well-known problems: the component renders once with no data, you have to track loading and error state by hand, and rapid navigation can produce race conditions where a stale response overwrites a newer one. Loaders solve these structurally. The router waits for the loader to resolve before swapping the view, cancels in-flight loaders when you navigate away, and surfaces thrown errors to the nearest errorElement.
| Concern | useEffect fetching | Route loader |
|---|---|---|
| When it runs | After first render | Before the route renders |
| Loading flash | Component mounts empty | No empty mount; router holds the transition |
| Race conditions | Manual AbortController | Stale loaders cancelled automatically |
| Errors | Local try/catch + state | Bubble to errorElement |
| Code location | Inside the component | Co-located with the route config |
Route loaders and useLoaderData
A loader is an async function attached to a route. It receives a request and the matched params, returns data, and that data becomes available through the useLoaderData hook.
// main.jsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import UserPage from "./pages/UserPage";
import ErrorPage from "./pages/ErrorPage";
const router = createBrowserRouter([
{
path: "/users/:id",
element: <UserPage />,
errorElement: <ErrorPage />,
loader: async ({ params, request }) => {
const res = await fetch(`https://api.example.com/users/${params.id}`, {
signal: request.signal,
});
if (!res.ok) {
throw new Response("User not found", { status: res.status });
}
return res.json();
},
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
The component reads the resolved data synchronously — no loading branch required, because the router guarantees the data is present before mount.
// pages/UserPage.jsx
import { useLoaderData } from "react-router-dom";
export default function UserPage() {
const user = useLoaderData();
return (
<article>
<h1>{user.name}</h1>
<p>{user.email}</p>
</article>
);
}
Passing request.signal to fetch means the browser aborts the request automatically if the user navigates away mid-load — the race-condition handling you would otherwise write by hand.
Actions for mutations
Where a loader reads data, an action writes it. Actions run when a <Form> is submitted to the route. The router calls the action, then automatically revalidates every loader on the page so the UI reflects the new state without a manual refetch.
// route config
{
path: "/users/:id/edit",
element: <EditUser />,
loader: userLoader,
action: async ({ request, params }) => {
const formData = await request.formData();
const res = await fetch(`https://api.example.com/users/${params.id}`, {
method: "PATCH",
body: JSON.stringify({ name: formData.get("name") }),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Response("Update failed", { status: 500 });
return redirect(`/users/${params.id}`);
},
}
// pages/EditUser.jsx
import { Form, useLoaderData, useNavigation } from "react-router-dom";
export default function EditUser() {
const user = useLoaderData();
const navigation = useNavigation();
const saving = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" defaultValue={user.name} />
<button type="submit" disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
</Form>
);
}
The useNavigation hook exposes the pending state of the whole router, so you get accessible, framework-managed loading UI for free. Import redirect from react-router-dom to send the user elsewhere after a successful write.
Tip: Return a plain
Response(or useredirect) from loaders and actions rather than callinguseNavigateinside an effect. The router understandsResponseobjects natively and handles status codes, redirects, and errors consistently.
useFetcher for non-navigation requests
Sometimes you want to load or submit data without changing the URL — a “mark as read” toggle, an autosave, an inline like button. useFetcher runs a route’s action or loader without navigating, while still triggering revalidation.
// components/LikeButton.jsx
import { useFetcher } from "react-router-dom";
export default function LikeButton({ postId, liked }) {
const fetcher = useFetcher();
const busy = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit" disabled={busy}>
{liked ? "♥ Liked" : "♡ Like"}
</button>
</fetcher.Form>
);
}
Each useFetcher instance has its own state, so multiple widgets on the same page submit independently without interfering with each other or the page’s main navigation.
Deferred data for slow responses
When part of a page is slow, you do not have to block the entire route on it. Wrap the slow promise without awaiting it, return it from the loader, and render it with Await and useAsyncValue so the fast content paints immediately.
// loader
import { defer } from "react-router-dom";
export function dashboardLoader() {
return defer({
profile: fetchProfile(), // fast — awaited by Await's parent
activity: fetchActivityFeed(), // slow — streamed in
});
}
// pages/Dashboard.jsx
import { Suspense } from "react";
import { Await, useLoaderData } from "react-router-dom";
export default function Dashboard() {
const { activity } = useLoaderData();
return (
<Suspense fallback={<p>Loading activity…</p>}>
<Await resolve={activity}>
{(items) => <ActivityList items={items} />}
</Await>
</Suspense>
);
}
The router renders the page shell right away and streams the deferred section into the Suspense boundary when it resolves — fast perceived performance without giving up data-router guarantees.
Best Practices
- Co-locate each route’s
loaderandactionwith its route definition so data and UI stay in sync. - Always forward
request.signaltofetchin loaders to cancel stale requests on navigation. - Throw a
Responsefor expected failures (404, 401) and let anerrorElementrender them, rather than threading error state through props. - Use
useNavigation(orfetcher.state) for pending UI instead of a localisLoadingflag. - Reach for
useFetcherwhen a mutation should not change the URL, and for<Form>+actionwhen it should. deferonly the genuinely slow data; awaiting fast resources keeps the initial render simpler.- Prefer returning
redirect()from actions over imperative navigation in effects.