| name | remix-v2-forms-review |
| description | Reviews Remix v2 form code for manual fetch() mutations, native <form> misuse, wrong useNavigation/useFetcher choice, missing pending state, unbounded uploads, and intent-pattern violations. Use when reviewing form/mutation code in a Remix v2 codebase. |
Remix v2 Forms Code Review
See remix-v2-forms for canonical
patterns. This skill flags violations; the sibling skill teaches the patterns.
Quick Reference
Review Checklist
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 action is 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 onChange triggers.
- 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.
-
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.
-
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.
-
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.
-
Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
Additional Documentation
When to Load References
- Reviewing forms that call
fetch() / axios / native <form>, or choose between <Form> and useFetcher → 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
- Does every in-app mutation flow through a route
action (no manual fetch())?
- Is the
<Form> vs useFetcher choice driven by whether the URL should change?
- Is pending state derived from
useNavigation() / fetcher.state (never useState)?
- Are per-row spinners wired to per-row
fetcher.state (not page-global useNavigation)?
- Do file-upload forms set
encType="multipart/form-data" and use bounded handlers?
- Are FormData values validated before reaching the DB?
- Do multiple mutations on one resource use the intent pattern, not separate routes?
- 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
Form aliased (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.