skills/code-visionary/react-router-skills/loader-action-optimizer

loader-action-optimizer

SKILL.md

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.signal to 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();
  });
});

References

Weekly Installs
10
First Seen
Feb 9, 2026
Installed on
opencode10
gemini-cli10
github-copilot10
codex10
amp10
kimi-cli10