
Remix V2 Forms Review
- 28 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-forms-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-forms-review
- AI & Agent Building
- AI-coding skill
Remix V2 Forms Review by the numbers
- 28 all-time installs (skills.sh)
- Ranked #9,505 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-forms-reviewAdd 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 Code Review
See remix-v2-forms for canonical patterns. This skill flags violations; the sibling skill teaches the patterns.
Quick Reference
| Issue Type | Reference |
|---|---|
Manual fetch(), native <form>, wrong <Form> vs useFetcher choice | references/form-vs-fetcher.md |
useState loading flags, useNavigation for per-row, missing pending state | references/pending-state.md |
Unbounded memory uploads, missing encType, unvalidated FormData, mirrored optimistic state | references/uploads-validation.md |
| Route sprawl instead of intent pattern, PUT/DELETE without PE fallback | references/multi-action-routes.md |
Review Checklist
- [ ] In-app mutations use
<Form>or<fetcher.Form>, neverfetch()/axios - [ ]
<Form>is imported from@remix-run/react(not native<form>) for POST mutations - [ ]
useFetcherused when URL should NOT change (row toggle, inline edit) - [ ]
<Form>+redirect(...)used when URL SHOULD change (create, delete-then-list) - [ ] Pending state derived from
useNavigation()orfetcher.state, neveruseState - [ ] Per-row pending uses per-row
fetcher.state(not page-globaluseNavigation) - [ ]
useNavigation()calls checknavigation.formActionto scope to expected path - [ ] Optimistic UI reads
fetcher.formData/navigation.formDatadirectly (not mirrored) - [ ] Actions returning success
redirect(...), returningjson()only for errors / same-page - [ ]
<Form encType="multipart/form-data">on every file-upload form - [ ]
unstable_createMemoryUploadHandleralways hasmaxPartSize; large files use disk/stream handler - [ ]
FormDatavalues are validated/coerced before reaching the DB (noform.get(x) as string) - [ ] Multiple mutations on one route use intent pattern, not separate routes
- [ ]
method="put|patch|delete"is documented as JS-only, or rewritten as POST + intent - [ ]
nav.formMethod/fetcher.formMethodcompared against UPPERCASE strings ("POST","GET"); v2'sv2_normalizeFormMethoddefault returns UPPERCASE —=== "post"silently never matches.
Valid Patterns (Do NOT Flag)
These are correct Remix v2 usage and should not be reported:
- `<Form>` without `action` prop — posts to the current URL by convention; explicit
actionis optional. - GET `<Form>` — legitimate for search/filter UIs; hits the loader with form fields as URL search params and does NOT call an action. Most "hygiene" rules (intent,
redirect,encType) apply only to POST forms. - Multiple `useFetcher()` instances on one page — each call returns an independent submission channel; intentional for parallel mutations to different rows.
- `useSubmit()` in an event handler — correct programmatic submission for autosave, keyboard shortcuts, or
onChangetriggers. - Reading `fetcher.formData` during a submission — intended; this is the canonical optimistic source.
- `useActionData` data persisting after submission — known behavior; it returns the last action result until the next navigation or action.
- `navigate={false}` on `<Form>` — turns it into a fetcher form; equivalent to
<fetcher.Form>without holding a fetcher ref. - `unstable_` prefix on `parseMultipartFormData` / upload handlers — permanent in v2; do not flag as "unstable API".
Context-Sensitive Rules
Only flag these when the listed condition holds:
| Issue | Flag ONLY IF |
|---|---|
Native <form> instead of <Form> | Method is POST and the route has an action — GET forms and external-URL forms are fine |
| Missing pending state | The form is POST and there is no useNavigation() / fetcher.state read anywhere in the component |
Action returns json({ ok: true }) after a create | The route is a "/new" or creation surface — same-page edit forms legitimately return JSON |
method="put" / "patch" / "delete" | Progressive enhancement is in scope for the surface (public app) — admin/JS-only tools may opt out if documented |
Unbounded unstable_createMemoryUploadHandler | The upload accepts user-controlled files (not a fixed-size internal artifact) |
| Separate routes per mutation | The mutations operate on the same resource with compatible auth — sibling resources with different rules are fine |
useNavigation() without formAction filter | The component contains other navigation surfaces (sidebar <Link>, sibling forms) that would trigger false positives |
Mirroring fetcher.formData into state | The shadowed value drives a user-visible element (button label, count, toggle) — a local "is-editing" flag is unrelated |
Hard gates (before writing findings)
Run these in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.
1. Location evidence — Pass: Each issue lists a repo path and either a line range or a short verbatim quote from the file you read (not from memory or diff-only guesswork). Name the route module, the component, and the action if one exists.
2. Exemption check — Pass: For each issue, you can state in one line why it is not covered by Valid Patterns (Do NOT Flag) and any matching row in Context-Sensitive Rules.
3. Form-method check — Pass: Before flagging missing intent, missing encType, missing redirect, or missing pending state, you have confirmed the form is method="post" (or put|patch|delete). GET forms are legitimate for search/filter and trigger loaders, not actions — applying POST-form rules to them is a false positive.
4. Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
Additional Documentation
- Form vs fetcher misuse — manual
fetch(), native<form>, wrong primitive: references/form-vs-fetcher.md - Pending state anti-patterns —
useStateflags, page-global vs per-row, missing entirely: references/pending-state.md - Uploads & FormData validation — unbounded handlers, missing
encType, unvalidated keys, mirrored optimistic state: references/uploads-validation.md - Multi-action routes — intent-pattern violations, PUT/DELETE without PE fallback, missing submit button: references/multi-action-routes.md
- Canonical patterns — see sibling remix-v2-forms
When to Load References
- Reviewing forms that call
fetch()/axios/ native<form>, or choose between<Form>anduseFetcher→ form-vs-fetcher.md - Reviewing loading flags, spinners, disabled-button logic, per-row pending → pending-state.md
- Reviewing file uploads,
unstable_*handlers, FormData parsing, optimistic UI → uploads-validation.md - Reviewing routes with multiple mutations, intent fields, PUT/DELETE methods → multi-action-routes.md
Review Questions
1. Does every in-app mutation flow through a route action (no manual fetch())? 2. Is the <Form> vs useFetcher choice driven by whether the URL should change? 3. Is pending state derived from useNavigation() / fetcher.state (never useState)? 4. Are per-row spinners wired to per-row fetcher.state (not page-global useNavigation)? 5. Do file-upload forms set encType="multipart/form-data" and use bounded handlers? 6. Are FormData values validated before reaching the DB? 7. Do multiple mutations on one resource use the intent pattern, not separate routes? 8. Do POST forms have at least one real submit button for progressive enhancement?
False-Positive Notes
- A
<Form>rendering inside a non-route component is still tied to the
nearest route's action — read the route file before flagging "missing action".
useActionData()returning data after a successful submission is
expected behavior; the data persists until the next navigation. Only flag if a success banner is rendered unconditionally without a dismiss path.
- Code that imports
Formaliased (e.g.import { Form as RemixForm })
is still the Remix component — match on import source, not local name.
Before Submitting Findings
Complete Hard gates (especially gate 4), then report only issues that still pass the review-verification-protocol pre-report checks.
Form vs Fetcher Misuse
Flag code that bypasses Remix's mutation lifecycle or picks the wrong primitive for the job. See remix-v2-forms for the decision gates and canonical patterns.
Anti-pattern 1 — Manual fetch() for in-app mutations
// BAD
function CreatePost() {
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await fetch("/api/posts", { method: "POST", body: fd });
}}
>
<input name="title" />
<button>Create</button>
</form>
);
}Why bad: Bypasses the route action entirely. Loaders do not revalidate, so the UI shows stale data until a hard reload. Progressive enhancement is broken (the form does nothing without JS). There is no useNavigation/useFetcher pending state hook. Race-safe out-of-order submission handling is gone.
Fix: Replace with <Form method="post" action="/posts"> (URL changes) or <fetcher.Form method="post" action="/posts"> (stays in place). Move the network call into that route's action.
Exemptions — do NOT flag:
fetch()to a third-party URL (Stripe, Mapbox, an unrelated API).fetch()inside anactionorloaderon the server.fetch()in a webhook handler or background worker.
Anti-pattern 2 — Native <form> for POST mutations
// BAD
import { useActionData } from "@remix-run/react";
export default function Signup() {
return (
<form method="post"> {/* lowercase — native, not Remix */}
<input name="email" />
<button>Sign up</button>
</form>
);
}Why bad: It works — Remix actions accept native form posts — but a hard navigation happens on submit, scroll position resets, and there is no way to read pending state, no useNavigation.formData, no optimistic UI surface. The user loses every benefit of client enhancement.
Fix: Import Form from @remix-run/react. Native <form> is fine ONLY for forms that intentionally target external URLs or expect a full document reload (rare; document the reason).
Exemptions — do NOT flag:
<form action="https://external.example.com/...">— external target.<form method="get">— GET forms work identically as native or
<Form>; the Remix component still has benefits (no scroll reset) but the native version is not a bug.
<form>rendered inside a markdown/CMS-rendered body.
Anti-pattern 3 — useFetcher when the URL should change
// BAD — "create" flow with no redirect
function NewItem() {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action="/items">
<input name="title" />
<button>Create</button>
</fetcher.Form>
);
}Why bad: No history entry, no scroll reset, no shareable URL for the new record, and the user's back button now skips the just-completed flow. Usually a sign the developer worked around <Form> because the pending-state ergonomics felt clumsy.
Fix: Use <Form> and redirect(\/items/\${created.id}\) from the action. Derive pending state from useNavigation() with a formAction check.
Exemptions — do NOT flag:
- A "quick-add" widget where the user stays on the dashboard.
- Inline create-in-place rows in a table.
- Drafts/autosave where the URL deliberately stays put.
Anti-pattern 4 — <Form> when each row needs independent pending state
// BAD — every row flickers on any submission
function TaskList({ tasks }: { tasks: Task[] }) {
const nav = useNavigation();
return tasks.map((t) => (
<Form key={t.id} method="post" action={`/tasks/${t.id}/toggle`}>
<button disabled={nav.state !== "idle"}>
{nav.state !== "idle" ? "..." : "Toggle"}
</button>
</Form>
));
}Why bad: useNavigation is page-global. The moment any row submits, every other row's button disables. Also: a <Form> submission navigates, so clicking row 5 changes the URL to /tasks/5/toggle.
Fix: Use useFetcher() per row and key pending state off that fetcher's fetcher.state. Each useFetcher() call returns an independent submission channel.
function TaskRow({ task }: { task: Task }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action={`/tasks/${task.id}/toggle`}>
<button disabled={fetcher.state !== "idle"}>Toggle</button>
</fetcher.Form>
);
}Anti-pattern 5 — Action returning JSON on a create surface
// BAD
export async function action({ request }: ActionFunctionArgs) {
const fd = await request.formData();
const created = await createItem(fd);
return json({ ok: true, id: created.id });
}Why bad: The user is stranded on the /new URL after success. Refreshing prompts the browser to resubmit the POST. The back button revisits the form. Reset logic has to be wired manually.
Fix: return redirect(\/items/\${created.id}\). Only return json() from a create surface for validation errors or when the user must stay on the same page.
Verification before reporting
1. Confirm the form's method is POST (or PUT/PATCH/DELETE). GET forms are exempt from these rules. 2. Confirm there is a real route action (or that one is expected). fetch() against a deliberately non-Remix endpoint may be correct. 3. Confirm <form> is the lowercase native element, not the imported Form from @remix-run/react. A grep for from "@remix-run/react" in the file usually settles it. 4. For row-pending issues, confirm the surrounding list actually renders multiple instances — a one-row "list" is not a bug. 5. For "should redirect" findings, confirm the route's path segment implies creation (e.g. /items/new, /posts/create). A nested form on a dashboard or detail page may legitimately stay put.
Severity guidance
- High — Manual
fetch()for any POST in-app mutation; missing
encType on a file upload; trusting formData.get() as DB input.
- Medium — Native
<form>for POST;useFetcherwhere<Form>+
redirect is correct; action returning json on a /new route.
- Low —
<Form>whereuseFetcherwould be lighter (rarely worth
flagging unless the surface lists multiple rows).
Multi-action Routes & Method Choice
Flag route file sprawl that the intent pattern would collapse, missing submit buttons that break progressive enhancement, and method="put" / "patch" / "delete" without a JS-only acknowledgement. See remix-v2-forms for the canonical intent pattern.
Anti-pattern 1 — Separate routes for sibling mutations
// BAD — file tree
app/routes/
tweets.$id.like.tsx
tweets.$id.unlike.tsx
tweets.$id.retweet.tsx
tweets.$id.delete.tsx// Each route has a tiny action and no default export
export async function action({ params }: ActionFunctionArgs) {
await likeTweet(params.id!);
return json({ ok: true });
}Why bad: Four route files for one logical resource. Each duplicates parsing, auth checks, and revalidation scope. The component now juggles four useFetcher() instances or four <Form action> targets when one would do. Adds friction for new operations (every new mutation needs a new file).
Fix: Single route app/routes/tweets.$id.tsx with one action that switches on formData.get("intent"). Each operation is a <button name="intent" value="..."> inside one <fetcher.Form>.
export async function action({ request, params }: ActionFunctionArgs) {
const fd = await request.formData();
switch (fd.get("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>
);
}Exemptions — do NOT flag:
- The mutations live on genuinely different resources (
/posts/:id/like
vs /users/:id/follow).
- Each route owns substantively different auth, validation, or
revalidation requirements.
- One mutation is a webhook target and not a UI submission.
Anti-pattern 2 — Action chooses intent from non-button source
// BAD — intent inferred from field presence
export async function action({ request }: ActionFunctionArgs) {
const fd = await request.formData();
if (fd.has("like")) return like();
if (fd.has("retweet")) return retweet();
}Why bad: Only the clicked submit button's name=value lands in the body. Inferring intent from field presence couples the action to a specific render order and breaks under refactor. Pressing Enter in a text input submits the FIRST button in DOM order, so the inferred intent may not match user expectation.
Fix: Use <button name="intent" value="..."> and read formData.get("intent"). Order buttons so the first is the correct Enter-key default.
Older codebases may use _action instead of intent — treat as equivalent; do not flag the spelling difference unless the action handler hardcodes one name.
Anti-pattern 3 — method="put" / "patch" / "delete" without fallback
// BAD — works only with JS
<Form method="delete" action={`/posts/${id}`}>
<button>Delete</button>
</Form>Why bad: Native HTML forms only support GET and POST. Without JS, the browser degrades method="delete" to GET, which usually hits the loader (or 405s the action) — the delete silently never happens. Progressive enhancement is silently broken.
Fix: If progressive enhancement matters for this surface, use method="post" and dispatch via an intent field:
<Form method="post" action={`/posts/${id}`}>
<input type="hidden" name="intent" value="delete" />
<button>Delete</button>
</Form>If the surface is admin-only or JS-required, document the constraint near the form and move on. Flag missing acknowledgement, not the choice itself.
Exemptions — do NOT flag:
- The route is explicitly an internal admin tool with a JS requirement
documented in project conventions (e.g. AGENTS.md or CLAUDE.md), README, or a comment.
- The form is
method="get"— GET is the only other HTML-native verb
and works fine without JS.
Anti-pattern 4 — <button type="button"> as the only submit
// BAD — no native submit path
<Form method="post">
<input name="title" />
<button type="button" onClick={() => submit(formRef.current!)}>Save</button>
</Form>Why bad: Without JS, pressing Enter or clicking "Save" does nothing — the action never runs. Progressive enhancement is gone.
Fix: Keep at least one <button> (default type="submit") inside the <Form> body so the native submit path works. Use useSubmit() only for genuinely programmatic submission (autosave, keyboard shortcuts) and pair it with a real submit button as fallback.
Exemptions — do NOT flag: JS-only admin tool with documented constraint; controlled wizard step where the parent owns submit.
Verification before reporting
1. For separate-routes findings, read all sibling route files and confirm the mutations are genuinely on the same resource with compatible auth and validation. False positives are common when the routes look similar but encapsulate different domains. 2. For PUT/PATCH/DELETE findings, check whether the project has a declared no-JS-fallback stance (project conventions such as AGENTS.md or CLAUDE.md, repo README, or per-file comments). If so, downgrade to a low-severity note. 3. For intent-inference findings, confirm there is at least one <button name="intent"> somewhere — many codebases mix patterns inconsistently. 4. For missing-submit-button findings, confirm no <button> (without type="button") and no <input type="submit"> exists inside the <Form>.
Pending State Anti-patterns
Flag code that re-implements pending state Remix already owns, or wires the wrong observer to the wrong submission. See remix-v2-forms for the useNavigation vs fetcher.state decision gates.
Anti-pattern 1 — useState loading flag alongside <Form> / useFetcher
// BAD
function Signup() {
const [isLoading, setIsLoading] = useState(false);
return (
<Form
method="post"
onSubmit={() => setIsLoading(true)}
>
<input name="email" />
<button disabled={isLoading}>
{isLoading ? "Signing up..." : "Sign Up"}
</button>
</Form>
);
}Why bad: Duplicates state Remix already owns. The flag diverges from navigation.state on:
- Server-side errors (state never flips back if you forget the
useActionData effect).
redirect()responses (the new route mounts a fresh component, so
the flag is stuck true until cleanup runs).
- Double-submits and rapid clicks (no race-safe handoff).
- No JS — the button is enabled, the form posts, the flag never sets,
so any reliance on it breaks progressive enhancement.
Fix: Derive busy state from useNavigation() for <Form> or fetcher.state for useFetcher.
const nav = useNavigation();
const busy = nav.state !== "idle" && nav.formAction === "/signup";Filter by formAction whenever the surface might see other navigations (sidebar links, sibling forms) — otherwise unrelated nav lights up your spinner.
Anti-pattern 2 — useNavigation driving a per-row spinner
// BAD
function FavoriteList({ items }: { items: Item[] }) {
const nav = useNavigation();
return items.map((i) => (
<fetcher.Form key={i.id} method="post" action={`/items/${i.id}/fav`}>
<button disabled={nav.state !== "idle"}>Favorite</button>
</fetcher.Form>
));
}Why bad: useNavigation does not observe fetcher activity. If each row submits via its own useFetcher, the page-level navigation.state stays "idle" and the spinner never lights up.
Even if rows used <Form> instead, every row would disable on every submission because navigation.state is page-global.
Fix: Use one useFetcher() per row and key pending state off that fetcher's state. To express a true "anything in flight" indicator, use useFetchers() at the page level.
function Row({ item }: { item: Item }) {
const fetcher = useFetcher();
const pending = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action={`/items/${item.id}/fav`}>
<button disabled={pending} aria-busy={pending}>Favorite</button>
</fetcher.Form>
);
}Anti-pattern 3 — Missing pending state entirely
// BAD — user gets no feedback during a 2s mutation
export default function Comment() {
return (
<Form method="post">
<textarea name="body" />
<button>Post</button>
</Form>
);
}Why bad: The user double-clicks, submits twice, and waits with no indication anything happened. On a slow connection, the form looks broken.
Fix: Read useNavigation() (for <Form>) or fetcher.state (for useFetcher) and disable the submit button or surface a spinner. Filter on formAction so an unrelated nav (sidebar link) does not disable your form.
Exemptions — do NOT flag:
- The button is non-interactive after submit (e.g. unmounted by a
conditional render that depends on useActionData).
- The surface is read-only / no real mutation.
- A wrapping layout already renders a top-level pending indicator
(root-level useNavigation bar).
Anti-pattern 4 — useNavigation() without a formAction filter
// BAD — sidebar Link navigations also disable this button
const nav = useNavigation();
const busy = nav.state !== "idle";Why bad: navigation.state flips for ANY navigation in the route tree. Clicking a sidebar <Link> will disable an unrelated submit button and surface a spinner that has nothing to do with the form.
Fix: Compare navigation.formAction to the form's action path:
const nav = useNavigation();
const busy = nav.state !== "idle" && nav.formAction === "/signup";For forms posting to the current route, capture the route's path from useLocation() or hardcode the literal — both are correct.
Exemptions — do NOT flag:
- The button is in
root.tsxand intentionally reflects ALL nav. - The surface is a single-form route where no other navigation is
reachable (rare; usually still safer to filter).
Anti-pattern 5 — Reading fetcher.state from the wrong fetcher
// BAD — two fetchers, one observed
function Row({ item }: { item: Item }) {
const likeFetcher = useFetcher();
const deleteFetcher = useFetcher();
const busy = likeFetcher.state !== "idle"; // ignores delete
return (
<>
<likeFetcher.Form method="post" action={`/items/${item.id}/like`}>
<button disabled={busy}>Like</button>
</likeFetcher.Form>
<deleteFetcher.Form method="post" action={`/items/${item.id}/delete`}>
<button disabled={busy}>Delete</button>
</deleteFetcher.Form>
</>
);
}Why bad: Pending state of "like" is wired to the delete button. Visual feedback is inverted.
Fix: Either observe both (busy = like.state !== "idle" || delete.state !== "idle") or — better — use the intent pattern on a single fetcher so only the clicked button is "in flight". See multi-action-routes.md.
Verification before reporting
1. Grep the file for useState, useNavigation, useFetcher, fetcher.state. A real loading flag often shadows what is really happening. 2. Confirm <Form method> is POST (or non-GET). GET forms driven by useNavigation only ever produce "loading", never "submitting", so some patterns look different on GET. (<fetcher.Form method='get'> driven by fetcher.state DOES produce "submitting".) 3. Confirm the per-row vs page-global axis by reading the surrounding .map() — page-global checks are correct on a single-form route. 4. Confirm nav.formAction checks the right path; an outdated literal is worth flagging as a soft warning, not a critical bug.
Uploads, FormData Validation & Optimistic State
Flag upload handlers that can be weaponized for DoS, file inputs that silently strip their contents, FormData consumed without validation, and optimistic UI that drifts from fetcher.formData. See remix-v2-forms for the canonical upload and optimistic patterns.
Anti-pattern 1 — Unbounded unstable_createMemoryUploadHandler
// BAD — no maxPartSize, user input
import {
unstable_createMemoryUploadHandler,
unstable_parseMultipartFormData,
} from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const handler = unstable_createMemoryUploadHandler({}); // no cap
const fd = await unstable_parseMultipartFormData(request, handler);
await storeAvatar(fd.get("avatar"));
return redirect("/account");
}Why bad: The memory upload handler buffers entire uploads into RAM. Without maxPartSize, a single malicious POST with a multi-gigabyte body can OOM the server. This is a denial-of-service surface, not a theoretical concern — it is exploitable from the public internet by anyone who can submit the form.
Fix: Always pass maxPartSize, and prefer unstable_createFileUploadHandler (disk-backed) or a streaming upload directly to object storage for anything larger than ~1 MB.
const handler = unstable_createMemoryUploadHandler({
maxPartSize: 500_000, // 500 KB cap; reject larger uploads
filter: ({ contentType }) => contentType.startsWith("image/"),
});Exemptions — do NOT flag:
- Internal admin-only routes behind authentication where input size is
bounded by upstream constraints (still better to set a cap).
- A fixed-size internal artifact uploaded by a trusted job runner.
Anti-pattern 2 — Missing encType="multipart/form-data" on file upload
// BAD — file data is silently stripped
export default function AvatarRoute() {
return (
<Form method="post">
<input type="file" name="avatar" />
<button>Upload</button>
</Form>
);
}Why bad: Without encType="multipart/form-data", the browser encodes the form as application/x-www-form-urlencoded. request.formData() then yields the filename string for avatar, not a File instance. The upload silently fails — the action runs, sees a string where it expected a file, and either errors out or stores garbage.
Fix: Set encType="multipart/form-data" on the <Form> and parse in the action via unstable_parseMultipartFormData with a bounded upload handler.
<Form method="post" encType="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button>Upload</button>
</Form>Anti-pattern 3 — request.formData() values trusted as-is
// BAD
export async function action({ request }: ActionFunctionArgs) {
const fd = await request.formData();
await db.user.update({
where: { id: fd.get("id") as string },
data: {
email: fd.get("email") as string,
age: Number(fd.get("age")),
},
});
return redirect("/account");
}Why bad: FormData.get() returns FormDataEntryValue | null — a string, a File, or null. as string lies to the type system. An empty form field, a repeated key, a file upload masquerading as a text input, or a missing key all flow straight into the DB. Number() on null is 0; on an empty string is also 0. Coercion silently corrupts data.
Fix: Validate every field. Cast explicitly with String(fd.get("x") ?? ""), then run a schema validator (Zod, Valibot, or hand-rolled checks) before any DB call.
const id = String(fd.get("id") ?? "");
const email = String(fd.get("email") ?? "");
const age = Number(fd.get("age") ?? "");
if (!id) return json({ error: "missing id" }, { status: 400 });
if (!email.includes("@")) return json({ errors: { email: "invalid" } }, { status: 400 });
if (!Number.isFinite(age) || age < 0) return json({ errors: { age: "invalid" } }, { status: 400 });Exemptions — do NOT flag:
- A wrapper helper (e.g.
parseForm(request, schema)) is in use and
delegates validation. Flag only the call sites that bypass it.
- The action is server-internal (e.g. called only by another action via
fetch on the server) where input shape is provably bounded.
Anti-pattern 4 — Mirroring fetcher.formData into local state
// BAD
function FavoriteButton({ favorited }: { favorited: boolean }) {
const fetcher = useFetcher();
const [optimistic, setOptimistic] = useState(favorited);
return (
<fetcher.Form
method="post"
onSubmit={() => setOptimistic((v) => !v)}
>
<input type="hidden" name="favorited" value={String(!optimistic)} />
<button aria-pressed={optimistic}>Favorite</button>
</fetcher.Form>
);
}Why bad: Two sources of truth. On an action error, fetcher.data returns the failure but local state still shows the optimistic flipped value. The button is now lying about the server's state. The user clicks again, the state mismatches further, debugging gets ugly.
Fix: Read directly from fetcher.formData each render. It is populated synchronously on submit and cleared automatically when fetcher.state === "idle".
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}>Favorite</button>
</fetcher.Form>
);
}The same rule applies to navigation.formData for <Form> submissions.
Exemptions — do NOT flag:
- A genuine local UI state that does not represent the in-flight
submission (e.g. a modal-open flag).
- A debounced text input where local state is the controlled value and
only the submitted version flows through fetcher.formData.
Verification before reporting
1. For an unbounded-upload finding, confirm the unstable_create* handler is invoked without maxPartSize (or with a suspiciously high cap) on a route reachable by unauthenticated users. 2. For a missing encType finding, confirm at least one <input type="file"> is rendered inside the same <Form> and the method is POST. 3. For a FormData-validation finding, confirm there is no wrapper / schema helper called before the DB write. Grep for Zod / Valibot / parseForm first. 4. For a mirrored-optimistic-state finding, confirm the local state actually shadows what fetcher.formData already exposes — a local "is-editing" flag is not the same bug.