skills/loader-action-optimizer/SKILL.md
Best practices for React Router v7 loaders and actions - parallel fetching, deferred data, optimistic UI, and error handling patterns
npx skillsauth add code-visionary/react-router-skills loader-action-optimizerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Master React Router v7's data loading and mutation patterns. Learn how to fetch data efficiently, handle errors gracefully, and create responsive user experiences.
export async function loader({ request, params }: LoaderFunctionArgs) {
const data = await fetchData(params.id);
return { data };
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const result = await submitData(formData);
return redirect(`/success`);
}
export async function loader() {
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
return { users, posts, comments };
}
Loaders run before the route component renders, providing data to your page.
Key principles:
Actions handle form submissions and data mutations.
Key principles:
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 };
}
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 };
}
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 };
}
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>
);
}
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;
}
}
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 };
}
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>
);
}
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>
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>
);
}
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 };
}
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 };
}
export async function loader({ request, params }: LoaderFunctionArgs) {
const { signal } = request;
const data = await fetch(`/api/data/${params.id}`, { signal });
return data.json();
}
// 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;
}
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>;
}
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 };
}
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>
);
}
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();
}
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 });
}
Promise.all()defer() for non-critical data to improve perceived performanceredirect() for successful mutationsrequest.signal to fetch calls for automatic cancellationshouldRevalidate() to optimize when loaders rerunThings to avoid:
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();
});
});
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();
});
});
tools
TypeScript patterns for React Router v7 - Type-safe loaders, actions, params, generic components, and utility types
tools
Testing patterns for React and React Router v7 - Vitest, React Testing Library, route testing, mocking loaders/actions
development
Scaffold complete CRUD routes for React Router v7 - Generate list, create, details, and edit routes following best practices
development
React performance optimization patterns - React.memo, useMemo, useCallback, code splitting, and preventing unnecessary re-renders