Fetch Data and Handle Mutations with React Router v6 Loaders and Actions
Skip component-level useEffect fetching. React Router v6.4+ route loaders and actions load data before render and process form mutations through the router.
15 May 2026, 17:43 UTC

The problem: fetching inside components
In a client-side React application, data fetching often lives inside useEffect. The component mounts, shows a loading spinner, starts a fetch, and then renders the result. This pattern creates loading states inside the UI tree, sequential waterfalls as nested components mount, and manual cleanup logic for every form submission.
Useful takeaway
React Router v6.4 and later lets you move data loading to the route layer. Define loader and action functions on route objects passed to createBrowserRouter. A loader runs before the route component renders, and components read its return value with useLoaderData. An action handles form mutations, and components read its result with useActionData.
Version assumption
This guide targets react-router-dom 6.4.0 or later. React Router v5 does not include createBrowserRouter or route loaders. Verify your installed version before copying the configuration below.
Worked configuration
The example below defines a single route that lists projects and accepts a new project through a form. The loader fetches the list before ProjectList renders. The action handles the form submission.
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import ProjectList from "./ProjectList";
import ProjectError from "./ProjectError";
async function projectLoader() {
const response = await fetch("/api/projects");
if (!response.ok) {
throw new Response("Failed to load projects", { status: 500 });
}
return response.json();
}
async function projectAction({ request }) {
const formData = await request.formData();
const response = await fetch("/api/projects", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Response("Failed to create project", { status: 500 });
}
return response.json();
}
const router = createBrowserRouter([
{
path: "/",
element: <ProjectList />,
loader: projectLoader,
action: projectAction,
errorElement: ProjectError,
},
]);
export default function App() {
return <RouterProvider router={router} />
}
In JSX, element accepts a React element. The escaped tags above show how the route object is structured; in your source file, write JSX tags directly.
import { useLoaderData, useActionData, Form } from "react-router-dom";
export default function ProjectList() {
const projects = useLoaderData();
const actionData = useActionData();
return (
Projects
{actionData && (
Added: {actionData.name}
)}
Project name
Add project
{projects.map((project) => (
- {project.name}
))}
);
}
Form without an action prop submits to the current route. React Router intercepts the submission, calls the route action, and revalidates the loader data. useActionData holds the value returned by the most recent action on that route.
How the mechanism works
- Loader input: a loader receives an object containing
params,request, andcontext. It may return a value or a promise. The router waits for resolution before mounting the route component. - Action input: an action receives the same context object. Use
request.formData()to read submitted fields. Return data, redirect withnavigate, or throw aResponseto surface an error. - Serialization: loader and action return values must be JSON-serializable. Do not return class instances, functions, or DOM nodes.
- Error boundary:
errorElementrenders when a loader or action rejects, preventing a single failed route from crashing the whole application.
Limits and common mistakes
- Returning non-serializable data from a loader breaks hydration and state transfer. Keep loader output plain JSON.
- Using browser-only APIs such as
windoworlocalStorageinside a loader is unsafe in server-side rendering and may behave unexpectedly in some client environments. - Forgetting to return
navigate()or throwResponsefrom an action can leave the browser on the same URL with stale UI, even thoughuseActionDataupdates. - Loaders can re-run on navigation or revalidation. Do not assume a loader executes exactly once per page load.
Verification
- Open a terminal in your project root and run
npm list react-router-dom. No special permissions are required. Confirm the version is 6.4.0 or higher. - Add
console.log("loader executed")insideprojectLoader, navigate to the route, and confirm the log appears before the component mounts. - Submit the
Formand inspectuseActionData()in the component. Confirm it contains the value returned by the server, not the rawResponseobject.
Mutations write to your API. Test against a local or mock endpoint before pointing the form at production data. To revert this pattern, remove the loader and action properties from the route object and restore component-level fetching.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.