Replace useEffect with React Router Loaders and Actions: A Practical Guide
Stop the useEffect waterfall. Learn how React Router’s loaders and actions fetch and mutate data during navigation, provide a global pending UI, and centralize error handling – plus a concrete example and trade‑offs.
08 Oct 2026, 07:43 UTC

Why the old useEffect pattern is fragile
When you navigate quickly between pages, useEffect hooks that fetch data can race. The first request may still be pending when the second page mounts, and its response can overwrite the newer data. This “waterfall” problem forces developers to add manual cancellation logic or complex state flags.
React Router’s data router to the rescue
React Router 6.4+ introduces a data‑router API that ties data fetching to navigation events. A loader runs before a route renders, returning data that the route can consume via useLoaderData. An action handles form submissions or other mutations. Both run in the same environment (browser or server) and are automatically re‑invoked when navigation or an action completes.
Loader basics
import { createBrowserRouter, RouterProvider, useLoaderData } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/projects",
loader: async () => {
const res = await fetch("/api/projects");
if (!res.ok) throw new Error("Failed to load projects");
return res.json();
},
element: ,
errorElement: ,
},
]);
function ProjectsList() {
const projects = useLoaderData();
return projects.map(p => {p.name});
}
The loader runs whenever the user navigates to /projects. If the route is already mounted and the user clicks a link that stays on the same route but passes different params, the loader re‑runs automatically.
Action for mutations
function ProjectsList() {
return (
<form method="post" action="/projects">
<input name="name" placeholder="New project" required />
<button type="submit">Add</button>
</form>
);
}
const router = createBrowserRouter([
{
path: "/projects",
loader: /* same as above */,
action: async ({ request }) => {
const formData = await request.formData();
const res = await fetch("/api/projects", {
method: "POST",
body: formData,
});
if (!res.ok) throw new Error("Failed to add project");
return null; // no redirect; loader will re‑run
},
element: ,
errorElement: ,
},
]);
After the POST finishes, React Router automatically re‑invokes the loader for the same route, ensuring the list stays fresh without manual cache invalidation.
Global pending UI with useNavigation
The useNavigation hook exposes a state property that can be "idle", "submitting", or "loading". Instead of scattering isLoading flags, you can render a single spinner that covers the whole page.
function LoadingOverlay() {
const nav = useNavigation();
if (nav.state === "idle") return null;
return Loading…;
}
Place <LoadingOverlay /> once in your root layout. It will show during any loader or action request, regardless of which component triggered it.
Error handling in the router hierarchy
Instead of try/catch inside every fetch, React Router lets you declare an errorElement per route. When a loader or action throws, the nearest error element renders, and you can access the error via useRouteError.
function ProjectsError() {
const error = useRouteError();
return Error: {error.message};
}
This centralizes failure states and keeps component code focused on UI.
When loaders aren’t the right tool
- Polling or infinite scroll: Loaders run on navigation, not on a timer. Use
fetcheror a custom hook for background refreshes. - Background data for non‑page components: If a component needs data that isn’t tied to a route change, fetch it directly or via a context.
- Large, user‑specific data: Loaders execute on every navigation, which could be overkill for data that rarely changes. Consider caching strategies outside the router.
Version notes and safety checks
Data router APIs are stable in React Router 6.4+, but the exact shape of createBrowserRouter, loader return types, and error handling differ in the older 6.x releases and the experimental v7 framework mode. Verify your react-router-dom version in package.json and consult the matching docs.
Because loaders run in the browser, never rely on them for sensitive data filtering. Perform all security checks on the server before the loader returns data.
Actionable next steps
- Audit your app for
useEffectfetches that trigger on every navigation. - Replace each with a loader on the corresponding route.
- Move POST/PUT/DELETE forms to
actionhandlers and remove manualfetchcalls. - Add a single
<LoadingOverlay />in your root layout. - Define
errorElementfor routes that can fail, and test error paths by throwing in a loader or action. - For data that needs polling or infinite scroll, keep using
fetcheror a custom hook.
By shifting to loaders, actions, and useNavigation, you eliminate race conditions, centralize error handling, and simplify your component logic. The trade‑off is that loaders are navigation‑bound, so keep polling or background refreshes in separate mechanisms. Happy coding!
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.