React Router Cheatsheet

Actions and Forms

Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Actions

An action handles a non-GET submission. After it resolves, the router automatically re-runs the loaders for the active routes, so your UI updates without any cache invalidation code.

{
  path: "users/:id",
  element: <User />,
  loader: userLoader,
  action: async ({ request, params }) => {
    const formData = await request.formData();
    const intent = formData.get("intent");

    if (intent === "delete") {
      await deleteUser(params.id);
      return redirect("/users");
    }

    const name = String(formData.get("name") ?? "");
    if (!name) return data({ errors: { name: "Required" } }, { status: 400 });

    await updateUser(params.id, { name });
    return { ok: true };
  },
}
Reading the bodyUse
A normal formawait request.formData()
A JSON fetcher submitawait request.json()
Rawawait request.text()
Methodrequest.method

Form

<Form> is a progressively-enhanced <form>. It posts to the route's action over fetch instead of reloading the page, and it works without JavaScript in framework mode.

import { Form } from "react-router";

<Form method="post">
  <input name="name" />
  <button type="submit">Save</button>
</Form>

<Form method="post" action="/users/42">…</Form>
<Form method="delete">…</Form>
<Form method="get" action="/search">…</Form>     {/* updates search params */}
<Form method="post" encType="multipart/form-data">…</Form>
<Form method="post" replace>…</Form>
<Form method="post" navigate={false}>…</Form>    {/* like a fetcher */}
<Form method="post" preventScrollReset>…</Form>
<Form reloadDocument method="post">…</Form>      {/* real browser submit */}

A <Form method="get"> is the cleanest search box you can write: the inputs become search params, the loader reads them, and the result is a shareable URL with no state management at all.

<Form method="get" action="/search">
  <input type="search" name="q" defaultValue={q} />
  <select name="sort" defaultValue={sort}>
    <option value="new">Newest</option>
    <option value="top">Top</option>
  </select>
  <button>Search</button>
</Form>

Action Data and Validation

import { useActionData, useLoaderData } from "react-router";

function EditUser() {
  const { user } = useLoaderData();
  const actionData = useActionData();        // undefined until the action returns

  return (
    <Form method="post">
      <input name="name" defaultValue={user.name} aria-invalid={!!actionData?.errors?.name} />
      {actionData?.errors?.name && (
        <p role="alert">✗ {actionData.errors.name}</p>
      )}
      <button>Save</button>
    </Form>
  );
}

Returning validation errors from the action, rather than throwing, keeps the user's typed values on screen and puts the error next to the field.

Pending UI

import { useNavigation } from "react-router";

function GlobalSpinner() {
  const navigation = useNavigation();
  // navigation.state: "idle" | "loading" | "submitting"
  const busy = navigation.state !== "idle";
  return busy ? <div role="status">Loading…</div> : null;
}
FieldIs
stateidle, loading, or submitting
locationWhere we are navigating to
formDataThe submitted data, during a submission
formActionThe action URL
formMethodThe method
json / textThe body for non-form submissions
// A submit button that disables and relabels itself
function SubmitButton({ children }) {
  const navigation = useNavigation();
  const submitting = navigation.state === "submitting";
  return (
    <button type="submit" disabled={submitting}>
      {submitting ? "Saving…" : children}
    </button>
  );
}

Because navigation.formData is available while the request is in flight, you can render the submitted values immediately for an optimistic update, then let revalidation replace them with the server's version.

Fetchers

A fetcher talks to a loader or action without navigating. Use it for anything that is not a page transition: a like button, an inline delete, a combobox, a poll.

import { useFetcher } from "react-router";

function LikeButton({ postId, liked }) {
  const fetcher = useFetcher();
  // Optimistic: trust the in-flight submission over the loaded data
  const optimistic = fetcher.formData
    ? fetcher.formData.get("liked") === "true"
    : liked;

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <input type="hidden" name="liked" value={String(!optimistic)} />
      <button aria-pressed={optimistic}>{optimistic ? "♥" : "♡"}</button>
    </fetcher.Form>
  );
}
const fetcher = useFetcher();

fetcher.load("/api/search?q=react");            // call a loader
fetcher.submit(formData, { method: "post", action: "/items" });
fetcher.submit({ q: "react" }, { method: "get", action: "/search" });
fetcher.submit(data, { method: "post", encType: "application/json" });

fetcher.state    // "idle" | "loading" | "submitting"
fetcher.data     // whatever the loader or action returned
fetcher.formData // in-flight submission data
fetcher.Form     // a <Form> that does not navigate

Each useFetcher() call is independent, so a list of rows each with its own delete button gets per-row pending state for free. Give a fetcher a key when you need to share its state across components: useFetcher({ key: "cart" }).

Revalidation Control

import { useRevalidator } from "react-router";

const revalidator = useRevalidator();
revalidator.revalidate();               // re-run every active loader
revalidator.state;                      // "idle" | "loading"
// Skip re-running an expensive loader when it cannot have changed
{
  path: "settings",
  loader: settingsLoader,
  shouldRevalidate: ({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }) => {
    if (formMethod === "post") return true;
    if (currentUrl.pathname === nextUrl.pathname) return false;
    return defaultShouldRevalidate;
  },
}

Loaders re-run after every action by default. That default is correct far more often than it is wasteful, so reach for shouldRevalidate only when you have measured a real problem.