Remix v2 form submissions and mutations. Use when implementing forms, optimistic UI, file uploads, or multi-action routes. Triggers on <Form>, useFetcher, us...
---
name: remix-v2-forms
description: Remix v2 form submissions and mutations. Use when implementing forms, optimistic UI, file uploads, or multi-action routes. Triggers on <Form>, useFetcher, useSubmit, useNavigation for pending state, unstable_parseMultipartFormData, fetcher.formData, intent-based actions, encType multipart.
---
# 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**:
```tsx
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 → idle` for 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](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](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](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()` in `root.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](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](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](references/optimistic-ui.md) for
`fetcher.formData` and `useNavigation.formData`, when to apply, and
reverting on failure.
- **File uploads**: See [references/uploads.md](references/uploads.md)
for `unstable_parseMultipartFormData`,
`unstable_createMemoryUploadHandler`,
`unstable_createFileUploadHandler`, and bounded handlers.
- **Intent-based actions**: See
[references/intent-actions.md](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 |
don't have the plugin yet? install it then click "run inline in claude" again.
by @clawhub
added explicit inputs with setup guidance, broke procedure into 8 numbered steps with clear inputs/outputs per step, added comprehensive decision points for form choice, state hooks, and error handling, documented edge cases including network failures and file size limits, specified output contract format, and made outcome signals testable.
use this skill when you need to handle form submissions, mutations, and data revalidation in remix v2 apps. reach for it whenever you're building forms that need pending states, optimistic ui, file uploads, multi-action routes, or non-navigating in-place edits. the canonical remix approach guarantees revalidation, progressive enhancement, and race-safe state transitions. anything outside this loop (raw fetch, preventDefault + client state, native forms for in-app mutations) breaks revalidation and silently loses data on refresh or concurrent requests.
remix dependencies (required)
@remix-run/react v2+: provides <Form>, useFetcher, useNavigation, useActionData, useSubmit, useFetchers@remix-run/node v2+: provides json, redirect, ActionFunctionArgs typeroute module setup (required)
action function with signature async (args: ActionFunctionArgs) => Responseargs.request (a web Request object) to parse formData(), headers, and method.tsx or .ts extensionform inputs (required per submission)
name attributes on every input, textarea, select you want to capturevalue on button elements if using intent pattern (multi-action routes)encType="multipart/form-data" on <Form> if uploading filesoptional external integrations
unstable_parseMultipartFormData(request, uploadHandler) in action. handler can be memory-based (unstable_createMemoryUploadHandler) or disk-based (unstable_createFileUploadHandler). requires setup in action body, not env vars.response.headers.append("set-cookie", ...) in action returnpending state context (required for ui)
useNavigation() returns {state: "idle" | "submitting" | "loading", formData?: FormData, formAction?: string, ...} at page leveluseFetcher().state returns same shape per fetcher instanceuseFetchers() returns array of all in-flight fetchers; read-only, no methods to invokeerror handling context (required for validation)
useActionData<typeof action>() reads the most recent action return (non-null only if action returned a Response, not on redirect)json({errors: {...}}, {status: 400}) to display validation without navigatingparse the incoming request and decide: redirect on success, or return json error data.
inputs: request object with method and body, any db/api clients injected into loader context
process:
const form = await request.formData() to parse form fields (or unstable_parseMultipartFormData(request, handler) for file uploads)form.get("fieldName") (single value) or form.getAll("fieldName") (array)json({errors: {field: "message"}}, {status: 400}) if invalidredirect("/path") to change url and revalidate all loaders, or json({data: ...}) to stay on page and return data to useActionDataedge cases:
request.method !== "POST" (or "PUT", "DELETE", "PATCH"), throw a 405 or handle explicitlyoutputs: Response object (either redirect(url) or json(data, {status}))
<Form> component (page navigation case)use this when submitting should change the url and revalidate loaders (create new record and route to detail page, delete and go back to list, multi-step flow).
inputs: route action from step 1, form fields, optional replace prop
process:
{Form} from "@remix-run/react"<Form method="post"> (or "put", "patch", "delete" matching your action)replace prop to replace history entry instead of pushing a new onename attributes to all inputsname="intent" value="action" if handling multiple actions on one routeedge cases:
outputs: html form element; submission triggers action and pending state updates
useNavigation() to read pending state (page form case)read the page-level navigation state to show spinners, disable buttons, or read optimistic formdata.
inputs: none (hook, call it in render) process:
const nav = useNavigation() in componentnav.state === "submitting" or nav.state === "loading" to know if request is in-flightnav.formAction === "/expected-route" if multiple forms on the page to avoid unrelated navigations triggering your spinnernav.formData?.get("field") to read the submitted value while pending (optimistic ui)state === "idle"edge cases:
nav.state goes idle → submitting → loading → idle on post/put/delete; only idle → loading → idle on get navigationnav.state resets to idle even though the server is still processingloading stateidle before useActionData updatesoutputs: state object for conditional ui
useActionData() to display server validation errorsread the json response from step 1 (only if action returned json, not if it returned redirect).
inputs: action result process:
const actionData = useActionData<typeof action>()actionData?.errors?.fieldName to conditionally render error messagesactionData is null (it was the previous action result)<typeof action> to get autocompletion on the data shapeedge cases:
useActionData does not change during pending state; the value is stale until the action completesuseActionData persists until that route's action runsoutputs: parsed json data for conditional rendering
useFetcher() for inline mutations (no url change case)use this when submitting should NOT change the url (favorite a post, increment a counter, inline edit a row, toggle a switch).
inputs: none (hook) process:
const fetcher = useFetcher() to get an independent submission channel<fetcher.Form method="post"> with form fields; it posts to the current route's action (or use action="/custom-route" to target a different route)fetcher.submit(data, {method: "post"}) programmatically (onChange, keyboard shortcut, etc.)fetcher.state and fetcher.data for pending state and response datafetcher.formData?.get("field") while fetcher.state !== "idle" for optimistic uiedge cases:
useFetcher() call is independent; two rows with separate fetchers do not share pending stateuseFetcher() in the same component twice, you get two fetchers (do not do this; store the fetcher in state or a ref)outputs: fetcher object with Form, submit(), load() methods and state, data, formData properties
when a single route action needs to handle create, update, delete, or other variants, switch on a hidden field.
inputs: form with multiple submit buttons, each with name="intent" value="action-name"
process:
<button name="intent" value="create">Create</button> and <button name="intent" value="delete">Delete</button> etc. (one button per action)const intent = formData.get("intent")edge cases:
outputs: single action function that handles multiple mutations
when accepting file inputs, parse with unstable_parseMultipartFormData and a bounded handler.
inputs: <Form encType="multipart/form-data"> with <input type="file" name="avatar">, action setup with upload handler
process:
encType="multipart/form-data" to <Form>{unstable_parseMultipartFormData, unstable_createFileUploadHandler} from "@remix-run/node"const form = await unstable_parseMultipartFormData(request, unstable_createFileUploadHandler({directory: "./uploads", maxPartSize: 5_000_000}))const file = form.get("avatar") (returns a File object, not a string)file instanceof File and file.size > 0 to detect if file was uploadedfile.name, file.stream(), file.type etc. as normaledge cases:
encType="multipart/form-data", form.get("avatar") returns the filename string, not a File; easy mistakeunstable_parseMultipartFormData reads the full request into memory or disk depending on handler; budget ram/disk accordinglymaxPartSize, handler throws; catch and return 413unstable_createMemoryUploadHandler), all files stay in-memory; only safe for tiny files or testingform.getAll("files") for multipleoutputs: parsed form with File objects for file fields
show the user their input immediately without waiting for server response; revert on error.
inputs: navigation.formData (page form) or fetcher.formData (fetcher), current data from useLoaderData or fetcher.data
process:
navigation.state !== "idle", read navigation.formData?.get("newTitle") and render it in the ui alongside or instead of the stale dataedge cases:
outputs: ui that feels fast because it reflects user input before server confirms
<Form> vs useFetcher vs fetchuse <Form method="post"> if:
/records/:id, deleting and returning to list, multi-step form completion)use useFetcher() if:
use fetcher.load(href) if:
never use fetch() or axios for in-app mutations against your own remix routes. it bypasses revalidation, loader cache, and action lifecycle. the only exception is external apis (stripe, github, slack) that have no remix route.
useNavigation() vs fetcher.state vs useFetchers()use useNavigation() if:
<Form> on the current pagenav.formAction === "/expected-route" to filter unrelated navigationsuse fetcher.state if:
useFetcher() on the current pageuseNavigation() will NOT show fetcher activity; they are separate channelsuse useFetchers() if:
fetcher.submit() or fetcher.load(); useFetchers() is read-onlyfetcher.state !== "idle" to see if any are pendinguseActionData vs client-side validationreturn json with errors from action if:
use html5 validation (required, pattern, type="email") if:
use intent pattern (multiple actions, one route) if:
use separate routes if:
if unstable_parseMultipartFormData throws during parsing:
json({errors: {upload: "File too large or malformed"}}, {status: 413})useActionData().errors.uploadif file size is legitimate but the request exceeds maxPartSize:
maxPartSize if needed, but do not set it higher than your memory/disk budgetif move or processing the file fails in action:
json({errors: {upload: "Failed to save file"}}, {status: 500}) on successful form submission via <Form>:
redirect(url) (http 302 or 303)useActionData returns null on the new route (old action result is discarded)<Form replace> was set)on successful form submission via useFetcher (action returns json):
json(data) (http 200)on validation error:
json({errors: {...}}, {status: 400})useActionData or fetcher.data contains the errors objecton server error during action:
json({...}, {status: 500})on file upload:
./uploads/avatar.png)pending state ui:
nav.state !== "idle" or fetcher.state !== "idle", buttons are disabled, spinners are shown, inputs reflect formDataidle and ui returns to normal (or shows errors)user knows the form worked if:
<Form>, action returns redirect)useFetcher, action returns json)user knows the form failed if:
useActionData)developer knows the skill is working if: