Using React Router v6 Loader and Action APIs for Centralized Data Fetching
Learn how React Router v6’s loader and action APIs centralize data fetching and mutations, eliminate race conditions, and simplify optimistic UI with a concrete todo‑list example.
30 Oct 2025, 19:43 UTC

Problem: Scattered data logic leads to race conditions
In a typical client‑side React app, data fetching lives in component effects or custom hooks. When a navigation triggers a mutation, the component may render with stale data before the fetch resolves, causing flicker or inconsistent UI. Teams often duplicate fetch calls across routes, making it hard to guarantee that a loader re‑runs after every mutation.
Thesis: Colocating fetch and mutation logic with routes eliminates race conditions and simplifies optimistic UI
React Router v6.4+ provides loader and action functions that run automatically on navigation and form submissions. By placing data‑access code inside the route definition, the router guarantees that loaders re‑validate after any action, giving you a single source of truth for each URL.
How loaders work
A loader is a function exported from a route module. It receives a LoaderFunction argument containing request info (params, search, etc.) and must return a promise that resolves to the data the component needs. The returned value is accessed with useLoaderData inside the route component.
// routes/todos.tsx
import { json, LoaderFunction } from 'react-router-dom';
export const loader: LoaderFunction = async ({ params }) => {
const res = await fetch(`/api/todos?userId=${params.userId}`);
if (!res.ok) throw new Response('Failed to load todos', { status: 500 });
return json(await res.json());
};
export default function Todos() {
const todos = useLoaderData();
return (
{todos.map(t => (
- {t.title}
))}
);
}
The loader runs only when the route is entered or when you explicitly call revalidate. It does not re‑run on every render, which prevents unnecessary network calls but means you must trigger a reload when data depends on frequently changing props.
How actions work
An action handles mutations (POST, PUT, DELETE, etc.) and receives a ActionFunction argument with the submitted data. After the action resolves, React Router automatically re‑validates the route’s loaders, ensuring the UI reflects the latest state.
// routes/todos.tsx (continued)
import { redirect, ActionFunction } from 'react-router-dom';
export const action: ActionFunction = async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get('title') as string;
const res = await fetch(`/api/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: params.userId, title })
});
if (!res.ok) throw new Response('Failed to create todo', { status: 500 });
// Redirect to the same route to trigger loader re‑validation
return redirect(`/users/${params.userId}/todos`);
};
// In the component, use useSubmit to call the action
import { useSubmit, useNavigation } from 'react-router-dom';
export default function Todos() {
const submit = useSubmit();
const navigation = useNavigation();
const todos = useLoaderData();
return (
<>
{
e.preventDefault();
submit(request, { replace: true });
}}>
Add
{/* todo list as before */}
);
}
Worked example: Todo list with optimistic UI
Below is a concise version that shows optimistic UI without writing extra state management. The action returns the newly created todo; the component uses useActionData to update the list immediately, while the loader re‑validates after the redirect.
// routes/todos.tsx (optimistic version)
import { json, LoaderFunction, ActionFunction, redirect } from 'react-router-dom';
import { useLoaderData, useActionData, useSubmit, useNavigation } from 'react-router-dom';
export const loader: LoaderFunction = async ({ params }) => {
const res = await fetch(`/api/todos?userId=${params.userId}`);
return json(await res.json());
};
export const action: ActionFunction = async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get('title') as string;
const res = await fetch(`/api/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: params.userId, title })
});
if (!res.ok) throw new Response('Failed to create todo', { status: 500 });
const newTodo = await res.json();
return json({ newTodo });
};
export default function Todos() {
const todos = useLoaderData();
const actionData = useActionData();
const submit = useSubmit();
const navigation = useNavigation();
const optimisticTodos = actionData?.newTodo
? [...todos, actionData.newTodo]
: todos;
return (
<>
{
e.preventDefault();
submit(request, { replace: true });
}}>
Add
{optimisticTodos.map(t => (
- {t.title}
))}
);
}
Trade‑offs and limitations
- Bundle size: Adding
useLoaderData,useActionData, and related TypeScript definitions adds a few kilobytes. In extremely small projects (< 5 KB gzipped) this may be noticeable. - Loader re‑run triggers: Loaders only re‑execute on navigation actions or when you call
revalidate. Data that depends on rapidly changing props (e.g., a WebSocket message) must be refreshed manually. - Error handling: If a loader or action throws or returns a rejected promise, React Router looks for the nearest
errorElement. Forgetting to define one lets the error bubble to the root error boundary or crash the app.
Actionable closing
- Verify you are on React Router ≥ 6.4:
npm ls react-router-dom. - Add a test route with a static loader (
return json({ hello: 'world' });) and confirmuseLoaderDatareturns it after navigation. - Create a form that calls
useSubmitto an action route; check thatuseActionDatacontains the returned payload or that a redirect occurs. - If you need optimistic UI, return the created/updated resource from the action and merge it with
useLoaderDatain the component. - Define an
errorElementfor each route (or a parent route) to gracefully handle loader/action failures.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.