
Remix V2 Forms
- 28 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-forms is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-forms
- AI & Agent Building
- AI-coding skill
Remix V2 Forms by the numbers
- 28 all-time installs (skills.sh)
- Ranked #9,462 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill remix-v2-formsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Remix v2 Forms & Mutations
Canonical mutation primitives for the @remix-run/react@^2 route-module framework. A correct Remix v2 mutation is: a <Form method="post"> (or <fetcher.Form>), an action that parses request.formData() and returns either redirect(...) or json(...), and UI that reads useActionData() (or fetcher.data) for errors plus useNavigation() (or fetcher.state) for pending state. Anything that bypasses this loop — fetch(), raw <form>, e.preventDefault() + client state — silently sacrifices revalidation, progressive enhancement, and race-safe transitions.
Quick Reference
`<Form>` + action:
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "");
if (!email.includes("@")) return json({ errors: { email: "Invalid" } }, { status: 400 });
await createUser({ email });
return redirect("/dashboard");
}
export default function Signup() {
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const busy = nav.state !== "idle" && nav.formAction === "/signup";
return (
<Form method="post" replace>
<input name="email" type="email" />
{actionData?.errors?.email ? <em>{actionData.errors.email}</em> : null}
<button disabled={busy}>{busy ? "Signing up..." : "Sign Up"}</button>
</Form>
);
}Primitives
| Name | Purpose |
|---|---|
<Form> from @remix-run/react | Navigating, progressively-enhanced form that posts to a route action and triggers full-page revalidation |
<Form navigate={false}> | Shorthand for "post via fetcher; do not navigate." Equivalent to <fetcher.Form> without holding a fetcher ref — useful when you only need pending state, not a programmatic handle |
useFetcher() | Non-navigating submission channel for inline mutations, list rows, popovers — same revalidation, no URL change |
useFetchers() | Read-only array of all in-flight fetcher states across the app. Use for global pending indicators (top-bar loader) without prop drilling. No Form/submit/load methods on the returned items — just formData, state, etc. |
useNavigation() | Observes page-level navigation; the source of truth for <Form> pending state |
useSubmit() | Programmatic submission (onChange autosave, keyboard shortcuts). Accepts HTMLFormElement, FormData, plain object (form-encoded), or plain object encoded as JSON via { encType: "application/json" } |
useActionData<typeof action>() | Read the most recent action result for the current route |
State transitions:
useNavigation().state:idle → submitting → loading → idlefor non-GET
form submissions; idle → loading → idle for GET navigation.
useFetcher().state:idle → submitting → loading → idle.
Asymmetry: useNavigation skips submitting for GET navigations; useFetcher does NOT — only fetcher.load() skips it. <fetcher.Form method='get'> and fetcher.submit(..., {method:'get'}) both transition through submitting.
Key Patterns
<Form> for navigation, useFetcher for in-place
<Form> changes the URL, adds history, and revalidates all loaders. useFetcher does the same revalidation but stays on the current URL. Each useFetcher() call returns an independent submission channel, so two rows submitting at once do not share pending state.
Intent pattern for multiple actions on one route
One action, switch on formData.get("intent"), distinct <button name="intent" value="..."> per operation. Only the clicked submit button's name=value lands in the body. See references/intent-actions.md.
Optimistic UI from formData
fetcher.formData and navigation.formData are populated synchronously on submit and cleared at idle. Read directly each render; never mirror into local React state. See references/optimistic-ui.md.
File uploads need encType="multipart/form-data"
Without it, request.formData() strips file data and you get the filename string instead of a File. Parse with unstable_parseMultipartFormData and a bounded upload handler. The unstable_ prefix is permanent in v2. See references/uploads.md.
Gates (decision sequencing)
Answer in order. Pass means the condition is true; pick the API on the same line and stop.
<Form> vs useFetcher
1. Does the URL need to change after the mutation (creating a record and routing to /records/:id, deleting and going back to a list, multi-step flow)?
- Pass →
<Form method="post">+redirect(...)from the action. Stop. - Fail → Step 2.
2. Is this a mutation against a row, cell, toggle, or sub-section while the user stays on the same page (favorite, like, increment quantity, inline edit)?
- Pass →
useFetcher()with<fetcher.Form>. Stop. - Fail → Step 3.
3. Is this loading data outside of normal navigation (popover content, combobox results, prefetch)?
- Pass →
fetcher.load(href). Stop. - Fail → Default to
<Form>. Navigation is the conservative
choice — revalidation and history work out of the box.
Hard rule: never reach for fetch() or axios for in-app mutations against your own Remix routes. That bypasses the action lifecycle and skips loader revalidation.
useNavigation vs useFetcher.state for pending state
1. Is the pending indicator global (page spinner in root, top-bar loading bar)?
- Pass →
useNavigation()inroot.tsx
(navigation.state !== "idle"). Stop.
- Fail → Step 2.
2. Was the mutation made with `useFetcher`?
- Pass → Use that fetcher's
fetcher.state.useNavigation()
will NOT reflect fetcher activity. Stop.
- Fail → Step 3.
3. Is the indicator scoped to one row/button inside a list where each row has its own fetcher?
- Pass → Use the per-row
fetcher.state(or look up by key via
useFetchers()) so other rows do not flicker. Stop.
- Fail → Step 4.
4. Is the indicator scoped to the form just submitted via `<Form>`?
- Pass →
useNavigation()AND check
navigation.formAction === "/expected-path" so unrelated navigations don't trigger your local spinner. Stop.
- Fail → Step 5.
5. Need to render an optimistic value?
- Pass → Read
navigation.formData?.get("field")(page form) or
fetcher.formData?.get("field") (fetcher) — both are populated while state !== "idle". Stop.
Additional Documentation
- `<Form>` component: See references/form.md for
<Form> vs native <form> vs fetch(), progressive enhancement, redirect-after-success, and validation error display via useActionData.
- `useFetcher`: See references/fetcher.md for
inline mutations, list operations, popovers, fetcher.state, fetcher.data, fetcher.Form, fetcher.submit, fetcher.load.
- Optimistic UI: See
references/optimistic-ui.md for fetcher.formData and useNavigation.formData, when to apply, and reverting on failure.
- File uploads: See references/uploads.md
for unstable_parseMultipartFormData, unstable_createMemoryUploadHandler, unstable_createFileUploadHandler, and bounded handlers.
- Intent-based actions: See
references/intent-actions.md for multiple actions on one route via the FormData intent field.
Comparison
| Concern | <Form> | useFetcher | Native <form> | fetch() |
|---|---|---|---|---|
| URL change / history entry | Yes | No | Yes (hard nav) | No |
| Works without JS | Yes | Yes | Yes | No |
| Revalidates loaders | Yes | Yes | Yes (hard reload) | No |
| Pending state hook | useNavigation() | fetcher.state | None | Manual |
| Optimistic input source | navigation.formData | fetcher.formData | None | Manual |
| In-app mutation use case | Create / delete / multi-step | Inline / row / toggle | External targets only | Never for own routes |
useFetcher
A non-navigating submission channel. useFetcher posts to a route action (or loads from a route loader) and triggers the same loader revalidation as <Form> — but it does not change the URL, does not add a history entry, and does not reset scroll. Each useFetcher() call returns an independent fetcher, so concurrent submissions in different components do not share pending state.
Signature
const fetcher = useFetcher<TLoaderOrAction>({ key? });
// Returned API
fetcher.Form // <fetcher.Form method="post"> — like <Form>, no nav
fetcher.submit(data, options?) // programmatic submit
fetcher.load(href) // GET-load data from a route's loader
fetcher.state // "idle" | "submitting" | "loading"
fetcher.data // last loader/action response (typed via generic)
fetcher.formData // FormData in flight (populated while state !== "idle")
fetcher.formAction // target action URL while in flight
fetcher.formMethod // method while in flightState transitions: idle → submitting → loading → idle.
Asymmetry: useNavigation skips submitting for GET navigations; useFetcher does NOT — only fetcher.load() skips it. <fetcher.Form method='get'> and fetcher.submit(..., {method:'get'}) both transition through submitting.
When to Reach for useFetcher
- Inline edit on a list row (favorite, like, increment, status toggle).
- Mutation from a popover/modal that should leave the underlying page
intact.
- Combobox results, popover content, prefetch —
fetcher.load(). - Any mutation where a URL change would be wrong (no shareable URL, no
new screen).
Use <Form> instead whenever the URL should change after the mutation (create a record, delete and go back to list, multi-step flow).
Inline Mutation (No URL Change)
// app/components/favorite-button.tsx
import { useFetcher } from "@remix-run/react";
export function FavoriteButton({ id, favorited }: { id: string; favorited: boolean }) {
const fetcher = useFetcher();
// Optimistic: trust the in-flight intent if present.
const pendingFavorited = fetcher.formData
? fetcher.formData.get("favorited") === "true"
: favorited;
return (
<fetcher.Form method="post" action={`/items/${id}/favorite`}>
<input type="hidden" name="favorited" value={String(!pendingFavorited)} />
<button aria-pressed={pendingFavorited}>
{pendingFavorited ? "Unfavorite" : "Favorite"}
</button>
</fetcher.Form>
);
}Each row gets its own useFetcher, so two rows submitting at once do not share a pending state. The optimistic pendingFavorited reads directly from fetcher.formData each render — no local state to drift on error.
List Operations
For a list where each row can mutate, each row owns its own fetcher. Page-global useNavigation() will NOT observe fetcher activity. If you want a "something is loading anywhere" indicator, use useFetchers() to enumerate all in-flight fetchers.
import { useFetchers } from "@remix-run/react";
function GlobalSpinner() {
const fetchers = useFetchers();
const anyBusy = fetchers.some((f) => f.state !== "idle");
return anyBusy ? <Spinner /> : null;
}Popovers and fetcher.load
fetcher.load(href) fetches a route's loader without navigating. Useful for hover cards, comboboxes, and prefetch.
import { useEffect } from "react";
import { useFetcher } from "@remix-run/react";
export function UserHoverCard({ id }: { id: string }) {
const fetcher = useFetcher<typeof loader>();
useEffect(() => {
if (fetcher.state === "idle" && !fetcher.data) {
fetcher.load(`/users/${id}`);
}
}, [id, fetcher]);
if (fetcher.state === "loading") return <p>Loading...</p>;
if (!fetcher.data) return null;
return <UserCard user={fetcher.data} />;
}Programmatic Submission with fetcher.submit
const fetcher = useFetcher();
// Submit raw FormData
const fd = new FormData();
fd.set("intent", "delete");
fetcher.submit(fd, { method: "post", action: `/items/${id}` });
// Submit a plain object (encoded as FormData)
fetcher.submit({ intent: "archive" }, { method: "post" });
// Post JSON instead of FormData
fetcher.submit({ ids: [1, 2, 3] }, {
method: "post",
encType: "application/json",
});When you submit with encType: "application/json", in the action use await request.json() — request.formData() will throw.
Reading fetcher.data
fetcher.data is the typed response of the last action or loader call through this fetcher. Use the generic useFetcher<typeof action>() to get the serialized action return type, including validation errors.
const fetcher = useFetcher<typeof action>();
const error = fetcher.data?.errors?.title;fetcher.data persists until the fetcher next submits, similar to useActionData.
Shared Fetchers via fetcherKey
A useFetcher({ key }) and a <Form navigate={false} fetcherKey={key}> with the same key share submission state. Useful when a submission should remain observable while the user navigates to a different component — e.g. a sidebar that watches an in-flight upload started on another route. Surprising when reused accidentally.
// Sidebar — observe whether a known fetcher is in flight
import { useFetchers } from "@remix-run/react";
function UploadStatus() {
const fetchers = useFetchers();
const upload = fetchers.find((f) => f.key === "avatar-upload");
return upload?.state !== "idle" ? <p>Uploading...</p> : null;
}Anti-Patterns
- `useFetcher` when the URL should change — no history entry, no
scroll reset, no shareable URL, back button skips the just-completed flow. Use <Form> + redirect().
- **
useNavigation()to drive a per-row spinner inside a list where
each row uses useFetcher** — useNavigation does not observe fetcher activity, so the spinner never lights up. Use the per-row fetcher.state.
- Single `useFetcher` shared across a list — submissions from
different rows clobber each other's formData and state. Each row needs its own fetcher.
- Mirroring `fetcher.formData.get("field")` into local React state
for optimistic rendering — two sources of truth. On error, fetcher.data returns the failure but local state still shows the optimistic value. Read from fetcher.formData each render.
Gotchas
useNavigationdoes not observe fetchers. UseuseFetchers()for
a page-wide "anything in flight" view.
fetcher.formDatais cleared when state returns toidle. After
success, render against the refreshed loader data; after error, derive UI from fetcher.data?.errors.
fetcher.Formwithmethod="get"calls the matched route's loader,
not its action — same as <Form method="get">.
- Two fetchers with the same explicit
keyshare state. Without a key,
each useFetcher() call is independent even in the same component.
<Form> from @remix-run/react
The navigating, progressively-enhanced form component. It posts to a route action, then triggers full-page revalidation of every loader in the matched route tree.
Signature
<Form
method="get | post | put | patch | delete" // Remix accepts lowercase JSX method and normalizes; v2 docs document them as uppercase. formMethod on useNavigation/fetcher always returns UPPERCASE.
action?={string}
encType?="application/x-www-form-urlencoded" | "multipart/form-data" | "application/json" | "text/plain"
replace?={boolean}
reloadDocument?={boolean}
navigate?={boolean} // false → behaves like <fetcher.Form>
fetcherKey?={string} // observed via useFetchers()
preventScrollReset?={boolean}
viewTransition?={boolean}
/><Form> vs native <form> vs fetch()
| Aspect | <Form> from @remix-run/react | Native <form> | fetch() in onSubmit |
|---|---|---|---|
Submits to route action | Yes | Yes (hard navigation) | Yes (if URL matches) |
| Loaders revalidate | Yes (client-side) | Yes (full reload) | No |
| Works without JS | Yes | Yes | No |
useNavigation() pending state | Yes | No (full reload) | No |
useNavigation().formData for optimistic UI | Yes | No | No |
| Scroll position preserved | Yes (configurable) | No | N/A |
Use <Form> from @remix-run/react for in-app mutations. Native <form> is fine only for forms that intentionally target external URLs or want a full document reload. Never use fetch() for in-app mutations — it bypasses the entire Remix action lifecycle, leaves loaders stale, and breaks progressive enhancement.
Progressive Enhancement
<Form method="post"> works before client JS hydrates. The browser performs a native POST, the server runs the action, returns a redirect or re-renders, and the page works. When JS is available, Remix intercepts the submission, runs the action, and revalidates loaders without a full page reload.
This is why useState/setIsLoading(true) is wrong for tracking submission: it does not exist before hydration, and it duplicates state Remix already owns. Always derive busy state from useNavigation().
PE caveats:
- Native HTML forms only support
method="get"andmethod="post".
Without JS, put / patch / delete degrade to get, which usually hits the loader instead of the action and 405s. If PE matters, use method="post" and dispatch via an intent field.
GET <Form>does not call the action — it triggers a navigation with
form fields as URL search params, hitting the loader. Useful for search/filter UIs; confusing if you expected the action to run.
Redirect After Success
Always redirect(...) on success. Returning json({ ok: true }) from a mutation that should redirect strands the user on the form URL: refreshing re-submits via the browser's POST-resubmit prompt, and the back button revisits the form.
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const title = String(form.get("title") ?? "");
if (!title) return json({ errors: { title: "Required" } }, { status: 400 });
const created = await createItem({ title });
return redirect(`/items/${created.id}`);
}Return json(...) only for validation errors or when the user must stay on the same page after the mutation.
Validation Errors via useActionData
useActionData<typeof action>() returns the most recent action result for the current route. It persists across renders until the user navigates away or another action runs — so success banners derived from actionData will linger until next nav.
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
const errors: Record<string, string> = {};
if (!email.includes("@")) errors.email = "Invalid email";
if (password.length < 12) errors.password = "Min 12 characters";
if (Object.keys(errors).length) return json({ errors }, { status: 400 });
await createUser({ email, password });
return redirect("/dashboard");
}
export default function Signup() {
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const busy = nav.state !== "idle" && nav.formAction === "/signup";
return (
<Form method="post" replace>
<input name="email" type="email" />
{actionData?.errors?.email ? <em>{actionData.errors.email}</em> : null}
<input name="password" type="password" />
{actionData?.errors?.password ? <em>{actionData.errors.password}</em> : null}
<button disabled={busy}>{busy ? "Signing up..." : "Sign Up"}</button>
</Form>
);
}redirect() on success clears useActionData and resets the form naturally on the new route. json({ errors }, { status: 400 }) re-renders the same route with the errors visible.
Validate Input — FormData.get() is string | File | null
FormData.get() returns FormDataEntryValue | null. Implicit coercion silently lets attackers submit empty strings, repeated keys, or files where strings are expected. Cast with String(form.get("x") ?? "") and run a schema validator (Zod, Valibot) before any DB call.
Programmatic Submission with useSubmit
import { Form, useSubmit } from "@remix-run/react";
export default function SearchBar() {
const submit = useSubmit();
return (
<Form
method="get"
action="/search"
onChange={(event) => submit(event.currentTarget, { replace: true })}
>
<input type="search" name="q" />
</Form>
);
}replace: true keeps the back button useful — one history entry instead of one per keystroke.
useSubmit also accepts FormData, URLSearchParams, or a plain object. With { encType: "application/json" } it posts JSON, not FormData — in the action use await request.json() because request.formData() will throw.
Reset Uncontrolled Form on Success
React does not unmount uncontrolled inputs across a same-route mutation, so the DOM keeps the old value until you call form.reset().
import { useEffect, useRef } from "react";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export default function CommentForm() {
const formRef = useRef<HTMLFormElement>(null);
const nav = useNavigation();
const actionData = useActionData<typeof action>();
useEffect(() => {
if (nav.state === "idle" && actionData?.ok) formRef.current?.reset();
}, [nav.state, actionData]);
return (
<Form ref={formRef} method="post">
<input name="body" />
<button>Post</button>
</Form>
);
}For fetchers, swap nav for fetcher and key the effect on fetcher.state / fetcher.data.
Anti-Patterns
- `onSubmit={(e) => { e.preventDefault(); fetch(...) }}` — bypasses
the action lifecycle, leaves loaders stale, breaks PE. Replace with <Form method="post" action="/..."> and move logic to the action.
- Native `<form method="post">` for in-app mutations — works but loses
client-side enhancement and gives no useNavigation hook. Import Form from @remix-run/react.
- `useState`/`setIsLoading(true)` for submission state — diverges
from navigation.state on errors and double-submits; stops working without JS. Derive from useNavigation().
- Returning `json({ ok: true })` when you should redirect — strands
the user on a stale URL. return redirect("/items/" + created.id).
Gotchas
useActionDatais per-route, not per-intent. To distinguish which
intent ran, include the intent in the response or use a separate useFetcher per intent.
<Form method="post">revalidates all loaders in the matched route
tree. For high-frequency mutations, use useFetcher for granular control or shouldRevalidate per route to opt out.
replaceandpreventScrollResetare independent. Setting one does
not imply the other.
navigate={false}on<Form>turns it into a fetcher form —
equivalent to <fetcher.Form> but without holding the fetcher reference. Use fetcherKey to observe it via useFetchers().
Multiple Actions on One Route (the intent Pattern)
A Remix route has exactly one action export. To handle multiple operations (like, retweet, delete) without sprawling into separate route files, encode the operation in a FormData field named intent and switch on it inside the single action.
The Pattern
// app/routes/tweets.$id.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
export async function action({ request, params }: ActionFunctionArgs) {
const form = await request.formData();
const intent = form.get("intent");
switch (intent) {
case "like":
return json(await likeTweet(params.id!));
case "retweet":
return json(await retweetTweet(params.id!));
case "delete":
return json(await deleteTweet(params.id!));
default:
throw new Response("Unknown intent", { status: 400 });
}
}
export default function Tweet() {
const fetcher = useFetcher();
const pendingIntent = fetcher.formData?.get("intent");
return (
<fetcher.Form method="post">
<button name="intent" value="like" disabled={pendingIntent === "like"}>
Like
</button>
<button name="intent" value="retweet" disabled={pendingIntent === "retweet"}>
RT
</button>
<button name="intent" value="delete">
Delete
</button>
</fetcher.Form>
);
}Why It Works
Only the clicked submit button's name=value lands in the form body. So when the user clicks "Like", formData.get("intent") is "like"; the other two buttons contribute nothing. This is HTML-standard form behavior and works without JS.
Per-button pending state comes from fetcher.formData?.get("intent") — populated synchronously on submit, cleared at idle.
When to Use It
- A row in a list has multiple related actions (like, retweet, delete).
- A form has a primary submit and a "Save as draft" variant.
- A modal handles a small group of related mutations.
When not to use it:
- Mutations operate on different resources (different URLs are clearer).
- Operations belong on a different route conceptually (e.g. user vs.
admin actions on the same record — split them).
- The intents have wildly different request shapes (validation gets
ugly fast).
Pattern: Page Form Variant
The same pattern works with page-level <Form>:
import { Form, useNavigation } from "@remix-run/react";
export default function PostForm() {
const nav = useNavigation();
const pending = nav.formData?.get("intent");
return (
<Form method="post">
<input name="body" />
<button name="intent" value="draft" disabled={pending === "draft"}>
Save Draft
</button>
<button name="intent" value="publish" disabled={pending === "publish"}>
Publish
</button>
</Form>
);
}Filter on nav.formAction too if the route can be the target of multiple unrelated forms.
Anti-Patterns
- Multiple `<form>` elements each posting to a different route just
to disambiguate operations. Forces route file sprawl, duplicates parsing, and breaks revalidation scope. Use one action with intents.
- Reading `useActionData()` to detect which intent ran, without
including the intent in the response. useActionData is per-route, not per-intent — the component can't tell whether the "like" or the "delete" returned. Either include the intent in the response (json({ intent: "like", ok: true })) or use a separate useFetcher per intent.
- Defaulting to a "happy" intent in the action when
intentis
missing. Better to throw 400 so a bug in the UI doesn't silently mutate the wrong way.
Gotchas
- **Pressing Enter in a text input submits the first button in DOM
order.** Order your <button> elements so the safe / common operation comes first. If you have a destructive action ("Delete"), put it last and consider requiring a confirmation step.
formData.get("intent")returnsFormDataEntryValue | null. Compare
against string literals; do not pass it directly to a discriminated union without narrowing.
- The
intentfield is just a convention — any name works as long as
the action and UI agree. intent is the conventional choice and matches the broader Remix ecosystem.
Optimistic UI
Remix v2 exposes the in-flight submission as FormData while the mutation is pending. Reading that FormData synchronously each render is the canonical way to show optimistic values — no local state, no mirroring, no drift.
The Two Sources
useNavigation().formData— theFormDataof the active page-level
<Form> submission. Populated when navigation.state !== "idle" for a non-GET form submission. Cleared at idle.
useFetcher().formData— theFormDataof the active fetcher
submission. Populated when fetcher.state !== "idle". Cleared at idle.
Both are the canonical optimistic source: populated synchronously on submit, automatically reverted when the action completes and loaders revalidate.
Why Not Local State
// Anti-pattern — do not do this
const [optimisticCount, setOptimisticCount] = useState(count);
function onSubmit() {
setOptimisticCount((c) => c + 1);
fetcher.submit(...);
}Two sources of truth. On error, fetcher.data returns the failure but optimisticCount still shows the incremented value. On reload, local state resets but the server may have completed the mutation. The user sees flicker, ghost values, and stale UI.
Instead, derive optimistic state from fetcher.formData directly.
Pattern: Counter / Quantity
import { useFetcher } from "@remix-run/react";
export function CartCount({ count }: { count: number }) {
const fetcher = useFetcher({ key: "add-to-bag" });
const inFlight = Number(fetcher.formData?.get("quantity") ?? 0);
const optimistic = count + inFlight;
return <span aria-live="polite">{optimistic}</span>;
}While the submission is in flight, fetcher.formData.get("quantity") holds the value the user submitted. When fetcher.state returns to idle, loaders have revalidated, count reflects the new server value, and inFlight is 0 again. No code path needs to "revert on failure": on failure, fetcher.formData clears and you fall back to the unchanged server count.
Pattern: Toggle (Favorite / Like)
import { useFetcher } from "@remix-run/react";
export function FavoriteButton({ id, favorited }: { id: string; favorited: boolean }) {
const fetcher = useFetcher();
const pendingFavorited = fetcher.formData
? fetcher.formData.get("favorited") === "true"
: favorited;
return (
<fetcher.Form method="post" action={`/items/${id}/favorite`}>
<input type="hidden" name="favorited" value={String(!pendingFavorited)} />
<button aria-pressed={pendingFavorited}>
{pendingFavorited ? "Unfavorite" : "Favorite"}
</button>
</fetcher.Form>
);
}The hidden input encodes the new target state. pendingFavorited is the single source of truth for what the UI should display — drawn from the in-flight FormData when present, otherwise from the server prop.
Pattern: Optimistic via useNavigation (Page Form)
For a <Form> submission (not a fetcher), read useNavigation().formData:
import { Form, useNavigation } from "@remix-run/react";
export default function EditTitle({ title }: { title: string }) {
const nav = useNavigation();
const optimisticTitle =
nav.formAction === "/title" && nav.formData
? String(nav.formData.get("title") ?? title)
: title;
return (
<>
<h1>{optimisticTitle}</h1>
<Form method="post" action="/title">
<input name="title" defaultValue={title} />
<button>Save</button>
</Form>
</>
);
}Filter on nav.formAction so unrelated navigations don't trigger this component's optimistic render.
When to Apply Optimistic UI
Apply it when:
- The mutation almost always succeeds (favorites, likes, increments,
toggles, quantity changes).
- The user's intent is unambiguous and easily reverted.
- A round-trip delay is perceptible (>~100ms) and would feel sluggish.
Skip it when:
- The mutation has meaningful failure modes (payment, account changes,
destructive deletes) — the user expects to see the actual result.
- The new value depends on server-computed data (auto-generated IDs,
derived fields, slugs).
- The optimistic render would mislead the user about real state
(insufficient funds, permission denied, conflict).
Reverting on Failure
You don't have to. When the action returns an error response, the fetcher transitions through loading to idle. fetcher.formData clears. Your render falls back to the server value. The UI "reverts" automatically.
What you should do on failure: read fetcher.data for the error and surface it.
const fetcher = useFetcher<typeof action>();
const pendingFavorited = fetcher.formData
? fetcher.formData.get("favorited") === "true"
: favorited;
const error = fetcher.state === "idle" ? fetcher.data?.error : null;
return (
<>
<fetcher.Form method="post" action={`/items/${id}/favorite`}>
<input type="hidden" name="favorited" value={String(!pendingFavorited)} />
<button aria-pressed={pendingFavorited}>...</button>
</fetcher.Form>
{error ? <p role="alert">{error}</p> : null}
</>
);Anti-Patterns
- Mirroring `fetcher.formData.get("field")` into `useState`. Two
sources of truth; ghost values on error.
- Setting optimistic state inside `onSubmit`. Stops working without
JS; diverges from fetcher.state on double-submit.
- Applying optimistic UI to destructive actions (delete account,
irreversible payment) — the user needs to see the real outcome.
- Optimistic IDs / slugs. Server-generated values cannot be guessed
client-side. Wait for fetcher.data or revalidation.
Gotchas
formData.get(key)returnsFormDataEntryValue | null(string or
File). Cast or compare explicitly: formData.get("favorited") === "true".
useNavigation().formDatais page-global. Filter onformActionto
avoid one form's submission lighting up another form's optimistic UI.
- After a successful action, the optimistic value should match the
server value — but loader revalidation is async. There is a brief window where fetcher.formData has cleared and the loader has not yet returned. Display the new server value as soon as it's available; the gap is normally a single tick.
File Uploads
Remix v2 ships file-upload helpers under permanent unstable_ prefixes. The API was renamed without the prefix in React Router v7 — in v2 you keep the prefix. Code that migrates needs to update imports.
The Three Helpers
| Import | Purpose |
|---|---|
unstable_parseMultipartFormData(request, uploadHandler) | Parse a multipart/form-data request body in an action. Returns Promise<FormData>. |
unstable_createMemoryUploadHandler({ maxPartSize?, filter? }) | In-memory upload handler. Returns a File. For small files only. |
unstable_createFileUploadHandler({ directory?, maxPartSize?, filter?, file? }) | Disk-backed upload handler. Streams to a directory. For larger files. |
All three live in @remix-run/node (or @remix-run/cloudflare for the Cloudflare adapter). The unstable_ prefix is permanent in v2 — do not strip it expecting it to work.
encType="multipart/form-data" Is Mandatory
Without it, request.formData() strips file data. formData.get("avatar") returns the filename string, not a File. The upload silently fails. Set encType="multipart/form-data" on the <Form> AND parse via unstable_parseMultipartFormData in the action.
Pattern: In-Memory Upload (Small Files)
// app/routes/avatar.tsx
import {
json,
redirect,
unstable_createMemoryUploadHandler,
unstable_parseMultipartFormData,
type ActionFunctionArgs,
} from "@remix-run/node";
import { Form } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
const uploadHandler = unstable_createMemoryUploadHandler({
maxPartSize: 500_000, // 500 KB cap; reject larger uploads
});
const formData = await unstable_parseMultipartFormData(request, uploadHandler);
const file = formData.get("avatar");
if (!(file instanceof File)) return json({ error: "No file" }, { status: 400 });
await storeAvatar(file);
return redirect("/account");
}
export default function AvatarRoute() {
return (
<Form method="post" encType="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button>Upload</button>
</Form>
);
}Memory handler is fine for avatars and small attachments (a few hundred KB). For anything larger, use unstable_createFileUploadHandler or stream directly to object storage.
Pattern: Disk-Backed Upload
import {
unstable_createFileUploadHandler,
unstable_parseMultipartFormData,
type ActionFunctionArgs,
} from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const uploadHandler = unstable_createFileUploadHandler({
directory: "/tmp/uploads",
maxPartSize: 10_000_000, // 10 MB
file: ({ filename }) => filename,
filter: ({ contentType }) => contentType.startsWith("image/"), // callbacks also receive `name`
});
const formData = await unstable_parseMultipartFormData(request, uploadHandler);
const upload = formData.get("attachment");
// upload is a NodeOnDiskFile — has .name, .size, .type, and .getFilePath()
// Move it to permanent storage, then delete the temp file.
return json({ ok: true });
}unstable_createFileUploadHandler streams the part to disk. The returned file object exposes the temp path; move or stream from there to your permanent store, then clean up.
Bound Every Handler
`unstable_createMemoryUploadHandler` has no default cap — without maxPartSize, it buffers the entire upload into RAM. `unstable_createFileUploadHandler` defaults maxPartSize to 3MB, which is usually still too generous for untrusted user input. Always set an explicit maxPartSize matched to the upload's purpose.
filter is the second line of defense: reject parts whose contentType or name doesn't match what you accept.
const uploadHandler = unstable_createMemoryUploadHandler({
maxPartSize: 500_000,
filter: ({ contentType, name }) =>
name === "avatar" && contentType.startsWith("image/"),
});Anti-Patterns
- `<Form method="post">` with `<input type="file">` but no `encType`.
The file silently isn't sent; formData.get("avatar") returns the filename string. Set encType="multipart/form-data".
- `unstable_createMemoryUploadHandler()` with no `maxPartSize`.
Unbounded RAM buffering — OOM risk. Always bound.
- Stripping the `unstable_` prefix in v2. The prefix is permanent in
Remix v2; only React Router v7 renamed these without it.
- Using the memory handler for user-uploaded photos / documents.
Anything over ~1 MB should stream to disk or object storage.
- Trusting `file.name` or `file.type` as security boundaries. Both
are user-controlled. Validate magic bytes, scan content, or run uploads through a trusted processor.
Gotchas
unstable_parseMultipartFormDatareturns the parsedFormData. Use
formData.get(name) instanceof File to discriminate text fields from uploads — get can return string, File, or null.
- Mixed forms (text fields + file inputs) work fine: text fields pass
through formData as strings; only file inputs invoke the upload handler.
- The Cloudflare adapter (
@remix-run/cloudflare) re-exports these
helpers with the same unstable_ names. Node and Cloudflare imports are interchangeable at the type level but pick one matching your runtime.
- Upload handlers run on the server. The
requestobject cannot be
consumed twice — call unstable_parseMultipartFormData(request, ...) exactly once per action.