Reviews Remix v2 form code for manual fetch() mutations, native <form> misuse, wrong useNavigation/useFetcher choice, missing pending state, unbounded upload...
---
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 [beagle-remix-v2:remix-v2-forms](../remix-v2-forms/SKILL.md) 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](references/form-vs-fetcher.md) |
| `useState` loading flags, `useNavigation` for per-row, missing pending state | [references/pending-state.md](references/pending-state.md) |
| Unbounded memory uploads, missing `encType`, unvalidated FormData, mirrored optimistic state | [references/uploads-validation.md](references/uploads-validation.md) |
| Route sprawl instead of intent pattern, PUT/DELETE without PE fallback | [references/multi-action-routes.md](references/multi-action-routes.md) |
## Review Checklist
- [ ] In-app mutations use `<Form>` or `<fetcher.Form>`, never `fetch()` / `axios`
- [ ] `<Form>` is imported from `@remix-run/react` (not native `<form>`) for POST mutations
- [ ] `useFetcher` used 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()` or `fetcher.state`, never `useState`
- [ ] Per-row pending uses per-row `fetcher.state` (not page-global `useNavigation`)
- [ ] `useNavigation()` calls check `navigation.formAction` to scope to expected path
- [ ] Optimistic UI reads `fetcher.formData` / `navigation.formData` directly (not mirrored)
- [ ] Actions returning success `redirect(...)`, returning `json()` only for errors / same-page
- [ ] `<Form encType="multipart/form-data">` on every file-upload form
- [ ] `unstable_createMemoryUploadHandler` always has `maxPartSize`; large files use disk/stream handler
- [ ] `FormData` values are validated/coerced before reaching the DB (no `form.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.formMethod` compared against UPPERCASE strings (`"POST"`, `"GET"`); v2's `v2_normalizeFormMethod` default 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 `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.
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)](#valid-patterns-do-not-flag) and any matching row in [Context-Sensitive Rules](#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](../../../beagle-core/skills/review-verification-protocol/SKILL.md) for this review.
## Additional Documentation
- **Form vs fetcher misuse** — manual `fetch()`, native `<form>`, wrong primitive: [references/form-vs-fetcher.md](references/form-vs-fetcher.md)
- **Pending state anti-patterns** — `useState` flags, page-global vs per-row, missing entirely: [references/pending-state.md](references/pending-state.md)
- **Uploads & FormData validation** — unbounded handlers, missing `encType`, unvalidated keys, mirrored optimistic state: [references/uploads-validation.md](references/uploads-validation.md)
- **Multi-action routes** — intent-pattern violations, PUT/DELETE without PE fallback, missing submit button: [references/multi-action-routes.md](references/multi-action-routes.md)
- **Canonical patterns** — see sibling [beagle-remix-v2:remix-v2-forms](../remix-v2-forms/SKILL.md)
## When to Load References
- Reviewing forms that call `fetch()` / `axios` / native `<form>`, or choose between `<Form>` and `useFetcher` → [form-vs-fetcher.md](references/form-vs-fetcher.md)
- Reviewing loading flags, spinners, disabled-button logic, per-row pending → [pending-state.md](references/pending-state.md)
- Reviewing file uploads, `unstable_*` handlers, FormData parsing, optimistic UI → [uploads-validation.md](references/uploads-validation.md)
- Reviewing routes with multiple mutations, intent fields, PUT/DELETE methods → [multi-action-routes.md](references/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 `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](#hard-gates-before-writing-findings) (especially gate 4), then report only issues that still pass the [review-verification-protocol](../../../beagle-core/skills/review-verification-protocol/SKILL.md) pre-report checks.
don't have the plugin yet? install it then click "run inline in claude" again.
restructured original into implexa's six-component format, extracted decision logic into explicit if-else branches, added external reference inputs and protocol requirements, expanded procedure with 12 discrete review steps, and clarified hard gates and outcome criteria.
this skill flags violations of Remix v2 form patterns in production code. use it when auditing form submission logic, mutation flows, upload handlers, and routing choices in a Remix v2 codebase. it assumes you have the canonical patterns doc (beagle-remix-v2:remix-v2-forms) available for reference and focuses solely on catching violations: manual fetch() calls instead of route actions, native <form> elements where <Form> is required, wrong useNavigation vs useFetcher choice, missing pending state derivation, unbounded upload handlers, unvalidated FormData, and multi-action route sprawl instead of the intent pattern.
routes/items.$id.jsx) with an action export and form component(s)components/ItemForm.jsx) rendering <Form>, <fetcher.Form>, or fetch() callsunstable_createMemoryUploadHandler or parseMultipartFormDataForm, useFetcher, useNavigation, useSubmit to @remix-run/react (not native DOM or external HTTP libraries)scan for manual HTTP calls in the target component or route action. look for fetch(), axios, fetch-polyfill, or similar in POST/PATCH/PUT/DELETE paths. record file path, line number, and the actual call syntax.
identify all form elements in the component. for each form, verify the import: <Form> must come from @remix-run/react, not the native HTML namespace. if the form has method="post" (explicit or implicit) and the route has an action, flag any native <form> tag.
check <Form> vs useFetcher() choice against the rule: use <Form> + redirect() when the URL should change (create, delete-then-list). use <fetcher.Form> when the URL stays the same (row toggle, inline edit, bulk action). record the form's action prop or lack thereof, the expected route destination, and the presence/absence of a redirect in the action.
verify pending state derivation for each POST form. scan the component for useNavigation() or per-row fetcher.state reads. confirm pending state is NOT derived from useState flags or manually tracked booleans. if a form is POST and the component has no pending state logic, record this.
check per-row mutation isolation for lists or tables with per-row actions (edit, delete, toggle). confirm each row uses its own useFetcher() instance and wires pending to that fetcher's .state, not the global useNavigation(). flag any row using useNavigation() without an explicit formAction filter.
validate pending state reads. if the component reads useNavigation(), confirm it checks navigation.formAction to scope to the expected route or confirm the component has no other navigation surfaces (links, sibling forms). if the component uses fetcher.state, confirm it is scoped to a specific formAction or intent.
scan upload forms for encType="multipart/form-data". flag any form that accepts file input without this attribute. if the route calls unstable_createMemoryUploadHandler, confirm it has maxPartSize set and that the handler is bounded. record the handler call and the maxPartSize value or its absence.
review FormData parsing in the action. trace form.get(key), form.getAll(key), and any unvalidated casts like form.get('id') as string. flag casts without prior schema validation or type coercion. confirm values are validated before being passed to the database.
audit multi-action routes for intent-pattern compliance. if a single route handles multiple mutations (create, update, delete on the same resource), confirm the action reads an intent field from the form and branches on it. if separate routes exist for the same resource type, verify they are not collapsible. record the route path, the action function signature, and the intent field name or its absence.
verify method declarations for non-GET forms. if the form uses method="put", "patch", or "delete", confirm progressive enhancement is documented in scope. flag PUT/DELETE forms without a POST + intent fallback if the surface is public or mobile-friendly. record the method, the route path, and any PE documentation.
check submit button presence on multi-action forms. for forms with an intent field or multiple mutations, confirm there is at least one real <button type="submit"> (not a <Link> or click handler). this ensures progressive enhancement works when JS fails.
validate optimistic UI logic if the component reads fetcher.formData or navigation.formData. confirm it reads directly from the form submission object, not from a mirrored useState state. flag any component that shadows formData into state and then renders from the state copy.
if the form is GET and the route has no action (it only has a loader), then the form is a search/filter surface and triggers the loader with URL params. do not apply POST form rules (intent, redirect, encType, pending state) to GET forms.
if the form is POST but the component has no useNavigation() or fetcher.state read, then flag missing pending state only if the component is interactive (has disabled buttons, spinners, or loading text that should change during submission). internal tools or static forms do not require pending state.
if the component reads useNavigation() without a formAction filter but the page has no other navigation surfaces (no sidebar links, no sibling forms), then the global useNavigation() is safe and does not need filtering. flag only when false positives are unavoidable (e.g. link navigation and form submission on the same page).
if a route has separate actions for create, update, and delete (e.g. routes/items.new.jsx, routes/items.$id.jsx, routes/items.$id.delete.jsx), this is legitimate route design. do not flag as "missing intent pattern" unless the mutations operate on the same resource with compatible auth and the surface would benefit from consolidation (e.g. an admin panel with one form for all three operations).
if the form uses navigate={false}, it is equivalent to <fetcher.Form> without holding a fetcher ref. do not flag this as a primitive choice error.
if the action returns json({ ok: true }) after a create or update, flag this only if the surface is a dedicated creation page (e.g. /items/new) and should redirect on success. same-page edit forms (edit-in-line, modal overlays) legitimately return JSON and stay on the same URL.
if unstable_createMemoryUploadHandler is called with maxPartSize set to a large value (e.g. 500MB), verify the upload surface is intended for admin uploads or internal tools. user-facing uploads should have lower bounds (5-50MB) and may need a disk or stream handler instead.
if FormData parsing uses form.get(key) as string without prior validation, flag this as a coercion without schema. if the code calls schema.parse(form.get(key)) or uses parseFormData, do not flag.
report each violation with:
routes/items.$id.jsx or components/ItemForm.jsx)fetch(...) call)<form> instead of <Form>", "missing pending state", "unbounded upload handler")findings are formatted as a list of structured objects or a markdown table, depending on the review output format. each finding includes a brief justification (one line) stating why the context-sensitive rule applies and the issue is not a false positive.
the skill succeeds when:
fetch() call in POST/PATCH/PUT/DELETE paths is identified and flagged with the route it should have used instead<form> with method="post" on a route with an action is flagged and marked for replacement with <Form>useFetcher used for navigation (with expectation that URL changes) is flagged as wrong primitive choice<Form> for row or in-page mutations (where URL should not change) is flagged as wrong primitive choiceuseNavigation() or fetcher.state reads is flagged for missing pending stateencType="multipart/form-data" is flaggedunstable_createMemoryUploadHandler call (without maxPartSize) on a user-facing upload is flagged<Form> without action, multiple useFetcher instances, navigate={false}, aliased imports)user knows the review succeeded when a markdown report or JSON listing is produced with clear, actionable findings. each finding cites a specific line or quote, states the rule violated, and suggests a canonical fix.