| name | loader-action-optimizer |
| description | Best practices for React Router v7 loaders and actions - parallel fetching, deferred data, optimistic UI, and error handling patterns |
| tags | ["react-router","performance","data-fetching","optimization"] |
| version | 1.0.0 |
| author | Code Visionary |
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) {
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) {
const user = await fetchUser(params.userId);
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) {
const user = await fetchUser(params.userId);
const analyticsPromise = fetchAnalytics(params.userId);
return defer({
user,
analytics: analyticsPromise
});
}
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) {
if (error.status === 404) {
throw new Response("Not Found", { status: 404 });
}
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();
const result = schema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
const user = await createUser(result.data);
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();
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" };
}
const url = await uploadFile(file);
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
export function shouldRevalidate() {
return false;
}
export function shouldRevalidate({
currentUrl,
nextUrl,
formMethod,
defaultShouldRevalidate
}) {
if (formMethod === "POST") return true;
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
});
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();
return defer({ critical, nonCritical });
}
Best Practices
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();
});
});
References