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 value | Effect |
|---|---|
| A plain object | Lands in actionData, page revalidates |
data(value, init) | Same, plus a status code or headers |
redirect(url) | Navigate, no actionData |
A thrown data() or Response | Renders 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> ); }
| Prop | Notes |
|---|---|
method | get, post, put, patch, delete. Only the first two degrade |
action | Target route. Defaults to the closest route in context |
encType | application/x-www-form-urlencoded default, or multipart/form-data, or text/plain |
replace | Replace the history entry after the submit |
navigate | false submits through an internal fetcher, no history entry |
fetcherKey | Pair with navigate={false} to read state elsewhere |
preventScrollReset | Keep the scroll position |
reloadDocument | Skip client routing entirely |
viewTransition | Wrap 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> ); }
| Member | What it is |
|---|---|
fetcher.Form | A Form that never navigates |
fetcher.load(href) | GET a route's loader on demand |
fetcher.submit(data, opts) | Submit without a form element |
fetcher.state | idle, loading, or submitting |
fetcher.data | The loader or action return value |
fetcher.formData | The 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.