Overview
Route loaders and actions are data APIs that attach data reads and writes to routes. A loader reads data before a route renders. An action handles a mutation, usually from a form submission, and then the router can revalidate loader data so the UI stays synchronized.
const router = createBrowserRouter([
{
path: "/projects/:projectId",
loader: async ({ params }) => {
return fetchProject(params.projectId);
},
action: async ({ request, params }) => {
const formData = await request.formData();
return updateProject(params.projectId, formData);
},
Component: ProjectPage,
},
]);
This model is common in data routers such as React Router and framework routers. Equivalent patterns exist in other React stacks: route modules, server loaders, route-level fetch functions, server actions, query libraries, and framework data-fetching conventions.
This topic matters for interviews because data loading is often where React applications become messy. A strong candidate should understand why route-level data loading can avoid fetch waterfalls, why mutations should revalidate cached data, when component-level effects are insufficient, how pending and error states work, and how to choose between route loaders, client caches, server rendering, and plain fetch calls.
The practical goal is to put data fetching and mutations near the route or feature boundary that owns them, while keeping UI states predictable.
Core Concepts
Why Route-Level Data Loading Exists
Component-level fetching often starts after a component renders.
function ProjectPage({ projectId }: { projectId: string }) {
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
fetchProject(projectId).then(setProject);
}, [projectId]);
return project ? <ProjectDetails project={project} /> : <Spinner />;
}
This can work, but it has drawbacks:
- The component renders before it knows its data.
- Parent and child fetches can waterfall.
- Race conditions need manual handling.
- Loading and error states are repeated in many components.
- Data ownership is spread across the tree.
Route loaders move data requirements to the route boundary:
{
path: "/projects/:projectId",
loader: async ({ params }) => {
return fetchProject(params.projectId);
},
Component: ProjectPage,
}
The route component can render with loader data already available.
Loaders
A loader is a function associated with a route that provides data to the route component.
{
path: "/teams/:teamId",
loader: async ({ params }) => {
const team = await fetchTeam(params.teamId);
return { team };
},
Component: TeamPage,
}
The component reads loader data:
function TeamPage() {
const { team } = useLoaderData() as { team: Team };
return <h1>{team.name}</h1>;
}
In framework mode, route components may receive loader data as generated props. In data-router mode, hooks such as useLoaderData are common.
Loader responsibilities:
- Read route-specific data.
- Validate route params.
- Redirect when required.
- Throw route errors for missing resources.
- Return data in a shape the route can render.
Params in Loaders
Loaders receive route params.
async function loader({ params }: LoaderArgs) {
const projectId = params.projectId;
if (!projectId) {
throw new Response("Missing project id", { status: 400 });
}
const project = await fetchProject(projectId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
return { project };
}
Params are strings and should be validated. If a loader cannot find required data, it should usually throw or return an intentional error response rather than rendering a broken component.
Route params make route data requirements explicit:
/organizations/:orgId/projects/:projectId
The loader can use both orgId and projectId.
Actions
An action handles a route mutation.
{
path: "/projects/:projectId",
action: async ({ request, params }) => {
const formData = await request.formData();
const title = String(formData.get("title") ?? "");
return updateProject(params.projectId, { title });
},
Component: ProjectPage,
}
Actions are commonly called by forms:
function ProjectForm() {
return (
<Form method="post">
<input name="title" />
<button type="submit">Save</button>
</Form>
);
}
After an action completes successfully, routers with data APIs can revalidate loader data so the UI reflects the latest server state.
Forms and Progressive Enhancement
Route actions pair naturally with route-aware forms.
<Form method="post" action="/projects/123">
<input name="title" />
<button type="submit">Save</button>
</Form>
This keeps mutation intent close to the HTML form. The action receives a Request, reads formData, performs the mutation, and returns data or redirects.
Benefits:
- Form submission maps to route mutation.
- Pending UI can be driven by navigation state.
- Validation errors can return as action data.
- Successful mutations can revalidate loaders.
- The model aligns with web fundamentals.
Fetchers and Non-Navigation Mutations
Sometimes a mutation should not navigate. Examples:
- Toggling a task complete.
- Saving an inline field.
- Adding an item without leaving the page.
- Submitting a background preference update.
Fetcher APIs support loader/action calls without navigation.
function TaskTitle({ task }: { task: Task }) {
const fetcher = useFetcher();
const busy = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action={`/tasks/${task.id}`}>
<input name="title" defaultValue={task.title} />
<button type="submit">
{busy ? "Saving..." : "Save"}
</button>
</fetcher.Form>
);
}
Use route navigation forms when the submission represents a page transition. Use fetchers for in-place mutations or background interactions.
Action Data and Validation
Actions can return data for the route component, often validation errors.
async function action({ request }: ActionArgs) {
const formData = await request.formData();
const title = String(formData.get("title") ?? "");
if (title.trim() === "") {
return {
errors: {
title: "Title is required",
},
};
}
await createProject({ title });
return redirect("/projects");
}
Component:
function NewProjectPage() {
const actionData = useActionData() as
| { errors?: { title?: string } }
| undefined;
return (
<Form method="post">
<input name="title" aria-invalid={Boolean(actionData?.errors?.title)} />
{actionData?.errors?.title && (
<p role="alert">{actionData.errors.title}</p>
)}
<button type="submit">Create</button>
</Form>
);
}
Validation errors are expected user input outcomes. They should usually be rendered as action data, not route error boundaries.
Revalidation
Revalidation means refreshing loader data after something changes.
After a successful action, data routers can re-run relevant loaders. This keeps the UI synchronized with server state.
Example:
async function action({ request }: ActionArgs) {
const formData = await request.formData();
await createTodo({ title: String(formData.get("title") ?? "") });
return { ok: true };
}
async function loader() {
return { todos: await getTodos() };
}
When the action creates a todo, the loader can re-run so the list shows the new item.
Revalidation is important because client state can otherwise become stale after mutations. In apps without route actions, query libraries often provide equivalent invalidation and refetch behavior.
Pending UI
Route-level data APIs expose navigation or fetcher state so the UI can show pending states.
function SubmitButton() {
const navigation = useNavigation();
const submitting = navigation.state === "submitting";
return (
<button disabled={submitting}>
{submitting ? "Saving..." : "Save"}
</button>
);
}
Pending UI should answer:
- Is the app loading the next route?
- Is a form submitting?
- Is a background fetcher busy?
- Should the current content stay visible?
- Should the user be prevented from duplicate submission?
Avoid blanking the whole page for every navigation. Keep stable layout visible and show localized pending states where possible.
Error Handling
Loaders and actions can throw errors or response-like values that route error boundaries handle.
async function loader({ params }: LoaderArgs) {
const project = await fetchProject(params.projectId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
return { project };
}
Use error boundaries for:
- Missing route data.
- Authorization failures that should block a route.
- Unexpected loader/action failures.
- Route-level 404s.
Do not use error boundaries for normal form validation. Return validation data from the action instead.
Redirects
Loaders and actions can redirect.
async function loader({ request }: LoaderArgs) {
const user = await requireUser(request);
if (!user) {
return redirect("/login");
}
return { user };
}
Actions often redirect after successful create/update/delete:
async function action({ request }: ActionArgs) {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
}
Redirects keep navigation decisions close to the data requirement or mutation result.
Equivalent Data-Loading Patterns
Not every React app uses route loaders and actions. Equivalent patterns include:
- Framework route loaders.
- Next.js server components and route segment data fetching.
- Remix-style route modules.
- TanStack Query or similar client cache libraries.
- Server actions.
- GraphQL clients with route-level prefetching.
- Custom route guards and data preloading.
The same principles still apply:
- Fetch data at the route or feature boundary when possible.
- Avoid avoidable network waterfalls.
- Keep loading and error states explicit.
- Invalidate or revalidate after mutations.
- Put authorization and not-found handling near the boundary that owns the data.
- Avoid scattering fetch effects across deeply nested components.
Client Cache Libraries vs Route Loaders
Route loaders are good when data is tied to navigation. Client cache libraries are good when data is reused across many components, needs background refresh, polling, optimistic updates, cache persistence, or complex invalidation rules.
Route loader strengths:
- Data is tied to URL and route boundaries.
- Load before rendering.
- Natural error and redirect handling.
- Good for page-level data.
Client cache strengths:
- Shared cache across components.
- Background refetching.
- Optimistic updates.
- Fine-grained invalidation.
- Reuse outside route transitions.
Many apps use both: route loaders for page bootstrapping and a query library for interactive data within the page.
Security and Boundaries
Route loaders and actions are boundary code. Treat them as places to enforce data access rules.
Important practices:
- Validate params.
- Check authentication and authorization.
- Validate form data.
- Do not trust client-provided IDs.
- Return only data the UI should receive.
- Avoid leaking internal errors to users.
- Use redirects or route errors for forbidden/missing resources.
Client-side route checks are not security. Server-side APIs or server route handlers must enforce the real rules.
Common Mistakes
Common mistakes include:
- Fetching all route data in
useEffectafter render when route loaders are available. - Fetching parent data and then child data in a waterfall.
- Not validating params before data access.
- Treating action validation errors as route errors.
- Forgetting to revalidate or invalidate data after mutations.
- Using a navigation form for small background updates that should use a fetcher.
- Using a fetcher for a mutation that should navigate after success.
- Showing full-page loading spinners for small background work.
- Putting auth checks only in client components.
- Mixing route loaders and client cache without clear ownership.
Best Practices
Use these rules of thumb:
- Put page-level reads in route loaders or equivalent route-level data APIs.
- Put route mutations in actions or equivalent mutation handlers.
- Keep component render code focused on displaying loaded data.
- Use action data for validation errors.
- Use route error boundaries for missing resources and unexpected failures.
- Revalidate or invalidate data after mutations.
- Use fetchers for in-place mutations that should not navigate.
- Use pending UI to prevent duplicate submissions and show progress.
- Validate params and form data at the boundary.
- Choose route loaders, framework data APIs, or query caches based on ownership and reuse.