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 export handles any non GET submission to a route. It runs on the server, and every loader on the page revalidates when it finishes.

// app/routes/project.tsx
import { redirect, data } from "react-router";
import type { Route } from "./+types/project";

export async function action({ request, params }: Route.ActionArgs) {
  const form = await request.formData();
  const title = String(form.get("title") ?? "").trim();

  if (!title) {
    return data({ errors: { title: "Required" } }, { status: 400 });
  }

  await db.project.update(params.id, { title });
  return redirect(`/projects/${params.id}`);
}
Return valueEffect
A plain objectLands in actionData, page revalidates
data(value, init)Same, plus a status code or headers
redirect(url)Navigate, no actionData
A thrown data() or ResponseRenders the nearest ErrorBoundary

clientAction is the browser equivalent, with the same signature plus a serverAction() function to call through to the server one.

Form

<Form> is a real form that works before JavaScript loads and gets upgraded to a fetch afterwards. Inputs are read by name.

import { Form } from "react-router";

export default function Project({ actionData }: Route.ComponentProps) {
  return (
    <Form method="post">
      <input name="title" defaultValue={loaderData.project.title} />
      {actionData?.errors?.title ? (
        <p>{actionData.errors.title}</p>
      ) : null}
      <button type="submit">Save</button>
    </Form>
  );
}
PropNotes
methodget, post, put, patch, delete. Only the first two degrade
actionTarget route. Defaults to the closest route in context
encTypeapplication/x-www-form-urlencoded default, or multipart/form-data, or text/plain
replaceReplace the history entry after the submit
navigatefalse submits through an internal fetcher, no history entry
fetcherKeyPair with navigate={false} to read state elsewhere
preventScrollResetKeep the scroll position
reloadDocumentSkip client routing entirely
viewTransitionWrap the resulting update in a View Transition

A <Form method="get"> is just a navigation that writes the URL, so it is the cheapest possible filter form.

Submitting Imperatively

import { useSubmit } from "react-router";

const submit = useSubmit();

// a form element or a FormData
submit(event.currentTarget, { method: "post" });

// plain object serialized as form data
submit({ id: "42", intent: "delete" }, { method: "post" });

// JSON body, request.json() on the server
submit({ id: 42 }, { method: "post", encType: "application/json" });

// auto submit a filter form on change
<Form onChange={(e) => submit(e.currentTarget)} method="get">

Fetchers

A fetcher calls a loader or action without navigating, so the URL, the scroll position, and the history stack are untouched. Use one for a like button, a combobox, or a list of independently deletable rows.

import { useFetcher } from "react-router";

function DeleteRow({ id }: { id: string }) {
  const fetcher = useFetcher();
  const busy = fetcher.state !== "idle";

  return (
    <fetcher.Form method="post" action="/rows/delete">
      <input type="hidden" name="id" value={id} />
      <button disabled={busy}>{busy ? "Deleting" : "Delete"}</button>
    </fetcher.Form>
  );
}
MemberWhat it is
fetcher.FormA Form that never navigates
fetcher.load(href)GET a route's loader on demand
fetcher.submit(data, opts)Submit without a form element
fetcher.stateidle, loading, or submitting
fetcher.dataThe loader or action return value
fetcher.formDataThe in-flight submission, for optimistic UI
fetcher.reset()Clear data back to undefined
useFetcher({ key })Share one fetcher's state across components
useFetchers()Every in-flight fetcher, for a global indicator

Fetcher submissions still trigger revalidation of the page's loaders, so the list updates itself after a delete with no manual refetch.

Optimistic UI

Render the submitted value before the server confirms it. Both a navigation and a fetcher expose the in-flight FormData.

function Toggle({ task }: { task: Task }) {
  const fetcher = useFetcher();
  const done = fetcher.formData
    ? fetcher.formData.get("done") === "on"
    : task.done;

  return (
    <fetcher.Form method="post">
      <input type="hidden" name="id" value={task.id} />
      <input
        type="checkbox"
        name="done"
        checked={done}
        onChange={(e) => fetcher.submit(e.currentTarget.form)}
      />
    </fetcher.Form>
  );
}

For a navigation rather than a fetcher, read useNavigation().formData the same way. Only do this where failure is cheap, otherwise show a pending state and let actionData carry the rejection.

One Route, Several Actions

Add a hidden intent field, or read the submit button's own name and value, and branch in the action.

<Form method="post">
  <button name="intent" value="publish">Publish</button>
  <button name="intent" value="delete">Delete</button>
</Form>
export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  switch (form.get("intent")) {
    case "publish":
      return publish(form);
    case "delete":
      return del(form);
    default:
      throw data("Unknown intent", { status: 400 });
  }
}

A submit button can override the target with formAction, formMethod, and formEncType, which is how one form posts to two routes.

File Uploads

<Form method="post" encType="multipart/form-data">
  <input type="file" name="avatar" />
  <button>Upload</button>
</Form>
export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const file = form.get("avatar");
  if (!(file instanceof File)) {
    return data({ error: "No file" }, { status: 400 });
  }
  await put(file.name, file.stream());
  return { ok: true };
}

request.formData() buffers the whole body in memory, so cap the size at the edge for large uploads. Validate type and size on the server, the accept attribute is only a hint.