All skills
remix-run avatar

/react-router-data-mode

@df990ec
by Remixremix-run/agent-skills136 stars
4

Build React applications using React Router's data mode with createBrowserRouter and RouterProvider. Use when working with route objects, loaders, actions, Form, useFetcher, or pending/optimistic UI without the Vite plugin.

Use this Skill: https://skilld.dev/gh/remix-run/agent-skills/react-router-data-mode

This session only. Nothing lands on disk.

referencespending-ui.md

≈2.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Pending UI and Optimistic Updates

Key principle: Show expected results immediately using fetcher.formData, then let React Router sync with the server.

Quick Reference

Pattern Hook Use Case
Optimistic mutations useFetcher + formData Likes, ratings, toggles
Global loading indicator useNavigation Page-level spinner
Link pending state NavLink Nav item loading indicator

For mutation patterns (when to use Form vs useFetcher), see actions.md.


useNavigation

Track global navigation state:

import { useNavigation, Outlet } from "react-router";

function Root() {
  const navigation = useNavigation();
  const isNavigating = Boolean(navigation.location);

  return (
    <div className={isNavigating ? "loading" : ""}>
      {isNavigating && <GlobalSpinner />}
      <Outlet />
    </div>
  );
}

Navigation States

  • navigation.state - "idle", "loading", or "submitting"
  • navigation.location - The location being navigated to (if any)
  • navigation.formData - Form data being submitted (if any)
  • navigation.formMethod - HTTP method of the submission
const navigation = useNavigation();

// Check if navigating
const isNavigating = navigation.state !== "idle";

// Check if submitting a form
const isSubmitting = navigation.state === "submitting";

// Check if loading after submission
const isLoading = navigation.state === "loading";

NavLink Pending State

Show pending state on the specific link being clicked:

import { NavLink } from "react-router";

function Nav() {
  return (
    <NavLink to="/dashboard">
      {({ isPending }) => <span>Dashboard {isPending && <Spinner />}</span>}
    </NavLink>
  );
}

Or use className:

<NavLink
  to="/dashboard"
  className={({ isPending }) => (isPending ? "pending" : "")}
>
  Dashboard
</NavLink>

useFetcher for Local State

Each fetcher tracks its own state independently:

import { useFetcher } from "react-router";

function LikeButton({ postId, liked }) {
  const fetcher = useFetcher();

  // Show pending state while submitting
  const isPending = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <button disabled={isPending}>
        {isPending ? "..." : liked ? "Unlike" : "Like"}
      </button>
    </fetcher.Form>
  );
}

Fetcher States

  • fetcher.state - "idle", "submitting", or "loading"
  • fetcher.data - Data returned from the action/loader
  • fetcher.formData - Form data being submitted

Optimistic UI with useFetcher (Recommended Pattern)

This is the standard pattern for mutations. Show the expected result immediately using fetcher.formData:

import { useFetcher } from "react-router";

function FavoriteButton({ itemId, isFavorite }) {
  const fetcher = useFetcher();

  // Optimistic: use pending form data, fallback to server state
  const optimistic = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : isFavorite;

  return (
    <fetcher.Form method="post" action={`/items/${itemId}/favorite`}>
      <input type="hidden" name="favorite" value={String(!optimistic)} />
      <button>{optimistic ? "★" : "☆"}</button>
    </fetcher.Form>
  );
}

Complete Optimistic UI Example

import { useFetcher } from "react-router";

function RatingStars({ itemId, currentRating }) {
  const fetcher = useFetcher();

  // 1. Check if we're submitting - use the pending value
  // 2. Otherwise use the server value
  const displayRating = fetcher.formData
    ? Number(fetcher.formData.get("rating"))
    : currentRating;

  const isSubmitting = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action={`/items/${itemId}/rate`}>
      <div style={{ opacity: isSubmitting ? 0.5 : 1 }}>
        {[1, 2, 3, 4, 5].map((star) => (
          <button key={star} type="submit" name="rating" value={star}>
            {star <= displayRating ? "★" : "☆"}
          </button>
        ))}
      </div>
    </fetcher.Form>
  );
}

Why fetcher.formData?

  • Instant feedback - UI updates immediately on click
  • No loading spinners needed - the optimistic state IS the loading state
  • Automatic rollback - if the action fails, loaders revalidate and reset to server state

Optimistic UI with useNavigation

For form submissions that navigate (using <Form> instead of useFetcher):

import { Form, useNavigation } from "react-router";

function NewProjectForm() {
  const navigation = useNavigation();

  // Get optimistic value from submission
  const optimisticTitle = navigation.formData?.get("title");
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <input type="text" name="title" />
      <button disabled={isSubmitting}>
        {isSubmitting ? "Creating..." : "Create"}
      </button>

      {optimisticTitle && <p>Creating "{optimisticTitle}"...</p>}
    </Form>
  );
}

Disabling During Submission

Prevent double submissions:

function ContactForm() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Form method="post">
      <input type="text" name="message" disabled={isSubmitting} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending..." : "Send"}
      </button>
    </Form>
  );
}

With useFetcher:

function CommentForm() {
  const fetcher = useFetcher();
  const isSubmitting = fetcher.state === "submitting";

  return (
    <fetcher.Form method="post">
      <textarea name="comment" disabled={isSubmitting} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Posting..." : "Post Comment"}
      </button>
    </fetcher.Form>
  );
}

Skeleton Loading Patterns

Show skeletons while data loads during navigation:

import { useNavigation, Outlet } from "react-router";

function Root() {
  const navigation = useNavigation();
  const isLoading = navigation.state === "loading";

  return (
    <div>
      <nav>...</nav>
      <main>{isLoading ? <PageSkeleton /> : <Outlet />}</main>
    </div>
  );
}

Route-specific Skeletons

function Root() {
  const navigation = useNavigation();
  const isLoading = navigation.state === "loading";
  const nextPath = navigation.location?.pathname;

  return (
    <div>
      <nav>...</nav>
      <main>
        {isLoading && nextPath?.startsWith("/dashboard") ? (
          <DashboardSkeleton />
        ) : isLoading ? (
          <GenericSkeleton />
        ) : (
          <Outlet />
        )}
      </main>
    </div>
  );
}

Busy Indicators with CSS

function Root() {
  const navigation = useNavigation();
  const isNavigating = navigation.state !== "idle";

  return (
    <div style={{ opacity: isNavigating ? 0.5 : 1 }}>
      <Outlet />
    </div>
  );
}

Progress Indicators

Show progress during slow navigations:

import { useNavigation } from "react-router";

function GlobalProgress() {
  const navigation = useNavigation();
  const isNavigating = navigation.state !== "idle";

  if (!isNavigating) return null;

  return <ProgressBar />;
}

function Root() {
  return (
    <div>
      <GlobalProgress />
      <nav>...</nav>
      <Outlet />
    </div>
  );
}

Complete Example

import {
  createBrowserRouter,
  RouterProvider,
  NavLink,
  Outlet,
  useNavigation,
  useFetcher,
} from "react-router";

const router = createBrowserRouter([
  {
    path: "/",
    Component: Root,
    children: [
      { index: true, Component: Home },
      {
        path: "items/:itemId",
        loader: async ({ params }) => fetchItem(params.itemId),
        action: async ({ request, params }) => {
          const formData = await request.formData();
          return toggleFavorite(
            params.itemId,
            formData.get("favorite") === "true",
          );
        },
        Component: Item,
      },
    ],
  },
]);

function Root() {
  const navigation = useNavigation();
  const isNavigating = navigation.state !== "idle";

  return (
    <div style={{ opacity: isNavigating ? 0.5 : 1 }}>
      <nav>
        <NavLink
          to="/"
          className={({ isPending }) => (isPending ? "pending" : "")}
        >
          Home
        </NavLink>
      </nav>
      {isNavigating && <GlobalSpinner />}
      <Outlet />
    </div>
  );
}

function Item() {
  const { item } = useLoaderData();
  const fetcher = useFetcher();

  // Optimistic UI
  const isFavorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : item.isFavorite;

  return (
    <div>
      <h1>{item.name}</h1>
      <fetcher.Form method="post">
        <input type="hidden" name="favorite" value={String(!isFavorite)} />
        <button>{isFavorite ? "★ Favorited" : "☆ Add to Favorites"}</button>
      </fetcher.Form>
    </div>
  );
}

See Also

Source: SKILL.md on GitHub

1 warning15d5 checks · Risk SAFE
  • Gen Agent Trust Hub15d

    This skill provides official documentation and best-practice code patterns for implementing React Router Data Mode. It is authored by the official maintainers and contains no malicious content or security threats.

  • Socket15d

    No alerts

  • Snyk15d

    Risk: LOW · No issues

  • Runlayer7mo

    8/8 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at df990ec. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last week.

Steadyupdated 8 months ago

README badge

README badge for remix-run/agent-skills/react-router-data-mode