Loader & Action Optimizer
Master React Router v7's data loading and mutation patterns. Learn how to fetch data efficiently, handle errors gracefully, and create responsive user experiences.
Quick Reference
Basic Loader Pattern
export async function loader({ request, params }: LoaderFunctionArgs) {
const data = await fetchData(params.id);
return { data };
}
Basic Action Pattern
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const result = await submitData(formData);
return redirect(`/success`);
}
Parallel Data Loading
export async function loader() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
return { users, posts, comments };
}
When to Use This Skill
- Setting up data loading for routes
- Optimizing multiple API calls
- Implementing form submissions
- Handling server-side validation
- Creating optimistic UI updates
- Streaming data with deferred responses
Core Concepts
Loaders: Fetching Data
Loaders run before the route component renders, providing data to your page.
Key principles:
- Run on the server (SSR) and client
- Execute in parallel for all matching routes
- Block navigation until complete (unless deferred)
- Should be fast - optimize aggressively
Actions: Mutating Data
Actions handle form submissions and data mutations.
Key principles:
- Only run on POST, PUT, PATCH, DELETE requests
- Execute before loaders
- Trigger revalidation of all loaders
- Return data or redirect
Loader Patterns
1. Single Data Source
import type { LoaderFunctionArgs } from "react-router";
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.user.findUnique({
where: { id: params.userId }
});
if (!user) {
throw new Response("Not Found", { status: 404 });
}
return { user };
}
2. Parallel Data Loading (Recommended)
export async function loader({ params }: LoaderFunctionArgs) {
// ✅ All requests start simultaneously
const [user, posts, followers] = await Promise.all([
fetchUser(params.userId),
fetchUserPosts(params.userId),
fetchUserFollowers(params.userId),
]);
return { user, posts, followers };
}
3. Sequential with Dependency
export async function loader({ params }: LoaderFunctionArgs) {
// First fetch required data
const user = await fetchUser(params.userId);
// Then fetch dependent data
const recommendations = await fetchRecommendations(user.preferences);
return { user, recommendations };
}
4. Deferred Data (Streaming)
Load critical data immediately, stream non-critical data later:
import { defer } from "react-router";
export async function loader({ params }: LoaderFunctionArgs) {
// Critical: Wait for this
const user = await fetchUser(params.userId);
// Non-critical: Don't wait
const analyticsPromise = fetchAnalytics(params.userId);
return defer({
user, // Available immediately
analytics: analyticsPromise // Resolves later
});
}
In your component:
import { Await, useLoaderData } from "react-router";
import { Suspense } from "react";
export default function UserProfile() {
const { user, analytics } = useLoaderData<typeof loader>();
return (
<div>
<h1>{user.name}</h1> {/* Shows immediately */}
<Suspense fallback={<Spinner />}>
<Await resolve={analytics}>
{(data) => <AnalyticsChart data={data} />}
</Await>
</Suspense>
</div>
);
}
5. Error Handling in Loaders
export async function loader({ params }: LoaderFunctionArgs) {
try {
const data = await fetchData(params.id);
return { data };
} catch (error) {
// Throw responses for expected errors
if (error.status === 404) {
throw new Response("Not Found", { status: 404 });
}
// Re-throw unexpected errors
throw error;
}
}
6. Authentication Check
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request);
if (!user) {
throw redirect("/login");
}
const data = await fetchPrivateData(user.id);
return { user, data };
}
Action Patterns
1. Form Submission with Validation
import { redirect } from "react-router";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// Validate
const result = schema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
// Process
const user = await createUser(result.data);
// Redirect on success
return redirect(`/users/${user.id}`);
}
In your component:
import { Form, useActionData } from "react-router";
export default function CreateUser() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" />
{actionData?.errors?.email && (
<span>{actionData.errors.email}</span>
)}
<input name="password" type="password" />
{actionData?.errors?.password && (
<span>{actionData.errors.password}</span>
)}
<button type="submit">Create User</button>
</Form>
);
}
2. Intent-Based Actions
Handle multiple actions in one route:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "delete":
await deleteItem(formData.get("id"));
return { success: true };
case "archive":
await archiveItem(formData.get("id"));
return { success: true };
case "update":
await updateItem(formData.get("id"), formData);
return redirect("/items");
default:
throw new Response("Invalid intent", { status: 400 });
}
}
In your component:
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<button type="submit">Delete</button>
</Form>
<Form method="post">
<input type="hidden" name="intent" value="archive" />
<button type="submit">Archive</button>
</Form>
3. Optimistic UI Updates
import { useFetcher } from "react-router";
function TodoItem({ todo }) {
const fetcher = useFetcher();
// Optimistic state
const isCompleted =
fetcher.formData?.get("completed") === "true"
? true
: todo.completed;
return (
<fetcher.Form method="post" action={`/todos/${todo.id}`}>
<input
type="checkbox"
name="completed"
value="true"
checked={isCompleted}
onChange={(e) => {
fetcher.submit(e.currentTarget.form);
}}
/>
<span style={{ opacity: isCompleted ? 0.5 : 1 }}>
{todo.text}
</span>
</fetcher.Form>
);
}
4. File Upload
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const file = formData.get("avatar") as File;
if (!file || file.size === 0) {
return { error: "No file provided" };
}
// Upload to storage
const url = await uploadFile(file);
// Update database
await updateUserAvatar(formData.get("userId"), url);
return { success: true, url };
}
Performance Optimization
1. Cache Loader Results
const cache = new Map();
export async function loader({ params }: LoaderFunctionArgs) {
const cacheKey = `user-${params.userId}`;
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const user = await fetchUser(params.userId);
cache.set(cacheKey, { user });
return { user };
}
2. Abort Stale Requests
export async function loader({ request, params }: LoaderFunctionArgs) {
const { signal } = request;
const data = await fetch(`/api/data/${params.id}`, { signal });
return data.json();
}
3. Revalidation Control
// Disable automatic revalidation
export function shouldRevalidate() {
return false;
}
// Conditional revalidation
export function shouldRevalidate({
currentUrl,
nextUrl,
formMethod,
defaultShouldRevalidate
}) {
// Only revalidate after POST requests
if (formMethod === "POST") return true;
// Don't revalidate on same URL
if (currentUrl.pathname === nextUrl.pathname) return false;
return defaultShouldRevalidate;
}
Common Patterns
Toast Notifications After Actions
export async function action({ request }: ActionFunctionArgs) {
await performAction();
return {
toast: {
type: "success",
message: "Action completed successfully!"
}
};
}
export default function Component() {
const actionData = useActionData<typeof action>();
useEffect(() => {
if (actionData?.toast) {
toast[actionData.toast.type](actionData.toast.message);
}
}, [actionData]);
return <div>...</div>;
}
Search Params Handling
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q") || "";
const page = Number(url.searchParams.get("page")) || 1;
const results = await searchItems({ query, page });
return { results, query, page };
}
Common Issues
Issue 1: Loaders Don't Rerun
Symptoms: Data doesn't refresh after navigation
Cause: React Router caches loader results
Solution: Use revalidate() or navigation options
import { useRevalidator } from "react-router";
function Component() {
const revalidator = useRevalidator();
return (
<button onClick={() => revalidator.revalidate()}>
Refresh Data
</button>
);
}
Issue 2: Race Conditions
Symptoms: Stale data appears when navigating quickly Cause: Slower requests complete after faster ones Solution: Use request.signal for automatic cancellation
export async function loader({ request }: LoaderFunctionArgs) {
const data = await fetch("/api/data", {
signal: request.signal // Auto-cancels on navigation
});
return data.json();
}
Issue 3: Slow Initial Load
Symptoms: Page takes too long to show
Cause: Loading too much data upfront
Solution: Use defer() for non-critical data
import { defer } from "react-router";
export async function loader() {
const critical = await fetchCritical();
const nonCritical = fetchNonCritical(); // Don't await!
return defer({ critical, nonCritical });
}
Best Practices
- Run independent fetches in parallel with
Promise.all() - Use
defer()for non-critical data to improve perceived performance - Throw Response objects for expected errors (404, 401, etc.)
- Validate form data in actions before processing
- Use
redirect()for successful mutations - Pass
request.signalto fetch calls for automatic cancellation - Return validation errors from actions instead of throwing
- Use
shouldRevalidate()to optimize when loaders rerun - Handle loading states with useNavigation
- Use useFetcher for non-navigation mutations
Anti-Patterns
Things to avoid:
- ❌ Sequential fetching when parallel is possible
- ❌ Fetching data in useEffect instead of loaders
- ❌ Not handling loading and error states
- ❌ Returning large payloads (serialize only what you need)
- ❌ Using actions for GET requests
- ❌ Ignoring validation in actions
- ❌ Not leveraging automatic revalidation
- ❌ Mixing client and server state management
Testing
Testing Loaders
import { loader } from "./route";
describe("loader", () => {
it("fetches user data", async () => {
const request = new Request("http://localhost/users/123");
const params = { userId: "123" };
const result = await loader({
request,
params,
context: {}
});
expect(result.user).toBeDefined();
});
});
Testing Actions
import { action } from "./route";
describe("action", () => {
it("validates form data", async () => {
const formData = new FormData();
formData.set("email", "invalid");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({ request, params: {}, context: {} });
expect(result.errors).toBeDefined();
});
});