Reviews Remix v2 loaders and actions for mutations-in-loader, missing validation, leaked server fields, wrong return helpers, v1 useTransition holdovers, and...
---
name: remix-v2-data-flow-review
description: Reviews Remix v2 loaders and actions for mutations-in-loader, missing validation, leaked server fields, wrong return helpers, v1 useTransition holdovers, and revalidation traps. Use when reviewing loader/action code in a Remix v2 codebase.
---
# Remix v2 Data Flow Code Review
Targets TypeScript route modules importing from `@remix-run/*`. See [beagle-remix-v2:remix-v2-data-flow](../remix-v2-data-flow/SKILL.md) for canonical patterns.
## Scope
- **In scope**: route modules under `app/routes/` exporting `loader`, `action`, `shouldRevalidate`, or `headers`; components that consume `useLoaderData`, `useActionData`, `useNavigation`, `useFetcher`, `useRevalidator`, `<Await>`.
- **Out of scope**: form ergonomics (`<Form>` markup, accessibility, `useFetcher` UI patterns) → covered by `remix-v2-forms-review`. Route module conventions, file naming, nested routing, error boundary placement → covered by `remix-v2-routing-review`.
- **Imports expected**: `@remix-run/node` (or `@remix-run/cloudflare` / `@remix-run/deno`) for server utilities; `@remix-run/react` for hooks and components.
## Quick Reference
| Issue Type | Reference |
|------------|-----------|
| Mutations in loader, missing validation, leaked server fields, throwing primitives, missing param checks | [references/loaders.md](references/loaders.md) |
| Unvalidated FormData, `json` instead of `redirect` on success, missing error case, leaked actionData | [references/actions.md](references/actions.md) |
| `useTransition` v1 holdover, missing pending state, blanket `shouldRevalidate: false`, misused `useRevalidator` | [references/revalidation.md](references/revalidation.md) |
| `defer` for already-fast data, missing `<Suspense>`, no `errorElement` on `<Await>`, awaiting what should stream | [references/defer-await.md](references/defer-await.md) |
## Review Checklist
- [ ] Data needed for first render is in `loader`, not `useEffect`
- [ ] Loaders only read; writes live in `action`
- [ ] `request.formData()` results are validated (zod/valibot/invariant) before use
- [ ] Loader/action return values are projected DTOs — no password hashes, tokens, or `internal_*` fields
- [ ] `useLoaderData<typeof loader>()` uses the type annotation form (not `as Foo`)
- [ ] 404 / auth short-circuits `throw` a `Response` (or `json`/`redirect`), never a plain `Error` or string
- [ ] Successful action returns `redirect(...)` (PRG); validation failures return `json({ errors }, { status: 400 })`
- [ ] Action handles both success and error branches; no silent `return null`
- [ ] `params.foo` is checked with `invariant` / zod before use
- [ ] Pending UI reads `useNavigation()` / `fetcher.state` — no `useTransition`
- [ ] `formMethod` comparisons use UPPERCASE (`"POST"`, not `"post"`)
- [ ] `shouldRevalidate` returns `defaultShouldRevalidate` by default; opt-outs are narrow and justified
- [ ] `defer()` is used only when at least one promise streams (no `await` before passing it)
- [ ] Every `<Await>` is wrapped in `<Suspense>` and has an `errorElement`
- [ ] `useRevalidator().revalidate()` is reserved for focus/polling/SSE — not called immediately after a `<Form>` post or `fetcher.submit` (Remix already revalidates).
## Valid Patterns (Do NOT Flag)
These are correct Remix v2 usage and must not be reported as issues:
- **`useEffect` for client-only data** — Loaders run server-side; `localStorage`, `window` dimensions, `IntersectionObserver`, and browser-only APIs belong in `useEffect`.
- **`loader` returning `null`** — A loader may legitimately return `null` (e.g. optional resource not present); flag only if it should be a 404 `throw`.
- **`useLoaderData<typeof loader>()` as type annotation** — The `<typeof loader>` is a generic parameter feeding `SerializeFrom<T>`, not a `as`-style type assertion. Do not flag it as "unsafe cast."
- **Bare `new Response(body, init)` returns** — v2 routes may return any `Response`; `json()` is an ergonomic wrapper, not a requirement. Non-JSON bodies (binary, text, streams) correctly skip `json()`.
- **`return redirect(...)` from an action** — Both `return redirect(...)` and `throw redirect(...)` are legal in actions; throwing is required only from non-action helpers when you want to exit the calling function.
- **`loader` declared without the `request` arg** — Loaders may destructure only what they need (`{ params }`, `{ context }`, or `()` with no args); the unused arg is not a bug.
- **Parent `loader` revalidated after an unrelated action** — This is default Remix behavior, not a smell. Flag only if `shouldRevalidate` exists and is wrong.
- **Action returning `json({ errors }, { status: 400 })`** — This is the canonical validation-error pattern (keeps the form route rendered with field errors). Not the same as the "no redirect on success" anti-pattern.
- **`useRevalidator` for focus / polling / cross-tab sync** — These are the documented use cases; only flag manual `revalidate()` calls that immediately follow a `<Form>` post or `fetcher.submit` Remix would already revalidate.
- **`SerializeFrom`-induced type changes** — `Date` typed as `string`, `Map` typed as `{}` after deserialization is correct wire-format behavior, not a typing bug.
## Context-Sensitive Rules
Only flag these issues when the specific context applies:
| Issue | Flag ONLY IF |
|-------|--------------|
| Missing loader (using `useEffect` instead) | Data is available server-side and is NOT a browser-only API read |
| `loader` returns a raw ORM object | The object contains fields a reviewer would not paste into a screenshot (passwords, tokens, internal flags) |
| Action returns `json` on success | The action is invoked via `<Form>` causing a URL change — NOT via `useFetcher` |
| Missing pending UI | No `nav.state` / `fetcher.state` reference exists elsewhere in the file driving the same surface |
| `shouldRevalidate` returns `false` | The body has no condition or never references `formAction` / `currentParams` / `nextParams` |
| Manual `useRevalidator().revalidate()` | The call follows a Remix-managed mutation (`<Form>` post, `fetcher.submit`) — not focus / polling / websocket |
| `defer()` used | Every promise in the `defer({...})` payload was already `await`ed before the call |
## 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 the repo path to the route module and either a line range or a short verbatim quote from the file you read (not from memory or diff-only guesswork). Loader/action issues without a path to the `export async function loader|action` are not reportable.
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). In particular: confirm `useEffect` is not loading client-only data; confirm a bare `Response` return is not intentionally non-JSON; confirm a `loader` returning `null` is not a legitimate optional read.
3. **Type-annotation vs type-assertion check** — **Pass:** Before flagging an "unsafe cast" on loader/action consumption, confirm the code uses `as` (assertion) — not `useLoaderData<typeof loader>()` (annotation) and not `useActionData<typeof action>()` (annotation). The generic form is the documented safe path and must not be flagged.
4. **v1 holdover check** — **Pass:** Before flagging "missing pending state," grep the file for `useTransition`, `transition.submission`, `fetcher.type`, `formMethod === "post"` or `formMethod==='post'` (lowercase, any whitespace/quote variation), and `LoaderArgs` / `ActionArgs`. If present, the finding is a v1-holdover migration issue, not a missing-feature issue — label it accordingly.
5. **Protocol** — **Pass:** You completed the Pre-Report Verification Checklist in [beagle-core:review-verification-protocol](../../../beagle-core/skills/review-verification-protocol/SKILL.md) for this review.
## When to Load References
- Reviewing a `loader` body, return shape, params, throws, or sensitive-field leaks → [references/loaders.md](references/loaders.md)
- Reviewing an `action` body, FormData validation, success/error branches, or PRG redirect → [references/actions.md](references/actions.md)
- Reviewing `useNavigation` / `useTransition` migrations, `shouldRevalidate`, or `useRevalidator` use → [references/revalidation.md](references/revalidation.md)
- Reviewing `defer()`, `<Await>`, `<Suspense>`, or streaming decisions → [references/defer-await.md](references/defer-await.md)
## Review Questions
1. Is data needed for first render fetched in a `loader`, or is it stuck in a `useEffect` that defeats SSR and revalidation?
2. Does every loader return a projected DTO, or do raw ORM records (with `password`, `token`, `internal_*` fields) leak to the browser?
3. Does every action validate `request.formData()` with a schema before touching the database?
4. Does the success branch of each action `redirect(...)` so refresh / back behaves correctly (PRG)?
5. Is the consumer code using `useLoaderData<typeof loader>()` (annotation) — not `useLoaderData() as Foo` (assertion)?
6. Do any v1 holdovers remain (`useTransition`, `transition.submission`, `fetcher.type`, lowercase `formMethod`, `LoaderArgs` / `ActionArgs`)?
7. Does `shouldRevalidate` return a literal `false`, or does it reach for `defaultShouldRevalidate` and opt out narrowly?
8. Is `defer()` used only when at least one promise is passed unresolved, and is every `<Await>` wrapped in `<Suspense>` with an `errorElement`?
## Additional Documentation
- Canonical Remix v2 data-flow patterns and v1 → v2 diff → [beagle-remix-v2:remix-v2-data-flow](../remix-v2-data-flow/SKILL.md)
- Pre-report verification checklist → [beagle-core:review-verification-protocol](../../../beagle-core/skills/review-verification-protocol/SKILL.md)
## Before Submitting Findings
Complete [Hard gates](#hard-gates-before-writing-findings) (especially gate 5), then report only issues that still pass the [beagle-core: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.
by @clawhub
separated skill into 6 required components (intent, inputs, procedure, decision points, output contract, outcome signal), added numbered step-by-step procedure with explicit inputs/outputs, explicated decision logic as if-else branches, documented external connections and reference paths as inputs, added hard-gates checklist as pre-flight verification, and preserved all original audit checks and valid patterns with lowercase tech-bro voice throughout.
this skill audits typescript route modules in a remix v2 codebase for data-flow violations: mutations inside loaders, unvalidated formdata, server secrets leaking to the browser, wrong return helpers (json instead of redirect on success), v1 useTransition holdovers, and revalidation antipatterns. run this when reviewing loader/action code or any consumer code that reads loader/action data via hooks like useLoaderData, useActionData, useNavigation, or useFetcher.
repo access: read access to route modules under app/routes/. each route module may export loader, action, shouldRevalidate, or headers functions, plus components consuming remix hooks.
external connections: none required. this is a static code review skill.
reference documents:
context: scope includes route modules targeting @remix-run/node, @remix-run/cloudflare, or @remix-run/deno. out of scope: form markup ergonomics (covered by remix-v2-forms-review) and route conventions/naming (covered by remix-v2-routing-review).
locate all loaders and actions , scan app/routes/ for export async function loader(...) and export async function action(...). note file paths and line ranges for each.
audit each loader for mutations , check the loader body for any writes: database updates, external API posts, file writes, or session mutations. if found, flag as "mutations in loader" with line reference and quote.
audit each loader for param validation , search for params.foo, params.bar, or destructured route params used without invariant(params.foo, ...) or zod validation before accessing. flag missing checks with line range.
audit each loader return for sensitive fields , examine the value returned by loader (either bare object or result of json()). scan for password hashes, API tokens, internal_* flags, or other server-only fields. if present, flag as "leaked server field" with field name and line.
audit each loader for thrown responses , check error paths. confirm that 404s and auth failures throw a Response object (via json() or redirect()) or call invariant(), never throw a bare Error or string. flag bare throws with line quote.
audit each action for formdata validation , examine const data = await request.formData() or const body = await request.json(). confirm the result is validated with zod, valibot, or explicit invariant() checks before use. flag missing validation with line range.
audit each action success branch for redirect , check the success path of each action. confirm it returns redirect(...) (post-redirect-get pattern), not json(...) when the action is invoked via <Form> causing a URL change. note the formMethod and return statement with line range.
audit each action error branch , confirm the action has an explicit error case returning json({ errors: ... }, { status: 400 }) or similar, never silent return null. flag missing error handling with line range.
audit each action return for leaked actiondata , scan the json() return value in an action for sensitive fields (same as loader step 4). flag leaks with field name and line.
audit consumer code for useLoaderData typing , find all useLoaderData() or useActionData() calls. confirm each uses the annotation form useLoaderData<typeof loader>(), not the assertion form useLoaderData() as MyType. flag assertions with line quote.
audit consumer code for pending ui , search for useNavigation() and useFetcher() calls. confirm state is read and used in conditional render logic for pending, success, or error states. flag missing pending-state handling only if no alternate surface in the file drives the same UI. note line range.
audit consumer code for v1 holdovers , grep for useTransition, transition.submission, transition.type, LoaderArgs, ActionArgs, and lowercase formMethod === "post". if found, flag as "v1 holdover" with specific identifiers and line range.
audit formMethod comparisons , search all formMethod === and formMethod == comparisons. confirm they compare uppercase ("POST", "GET", "PUT") not lowercase ("post"). flag lowercase with line quote.
audit shouldRevalidate logic , examine any shouldRevalidate function. confirm it does not return a bare false without condition. if it does, flag as "blanket revalidation opt-out" with line. if it returns defaultShouldRevalidate(...) by default, pass.
audit defer usage , find all defer({...}) calls. for each promise in the payload, check the code above the defer() to confirm the promise was not awaited. if all promises are unresolved, pass. if any promise was awaited, flag with line range and promise name.
audit Await and Suspense pairing , find all <Await> components. confirm each is wrapped in a <Suspense> with a fallback, and the <Await> has an errorElement. flag missing Suspense or errorElement with line range.
audit manual revalidator calls , search for useRevalidator().revalidate(). check the context: if the call immediately follows a <Form> post or fetcher.submit(), flag as "manual revalidate after mutation" (remix already revalidates). if it follows focus, polling, or websocket logic, pass.
verify location evidence , before drafting any finding, confirm you have the repo path, line range or line number, and a short verbatim quote from the actual file. do not report findings without this evidence.
verify exemption check , for each finding, state why it is not a valid pattern (see valid patterns below). if it matches a valid pattern, do not report it.
verify type-annotation check , before flagging a "cast" issue, confirm the code uses as (assertion), not <typeof loader>() (annotation). the generic form is correct and must not be flagged.
verify v1 migration context , if you flagged "missing pending ui" or "useTransition," confirm the file actually contains v1 code. if so, label the finding as a "v1 holdover" not a "missing feature."
run pre-report verification , complete the checklist in beagle-core:review-verification-protocol before submitting findings.
compile findings , output a list of issues in the format: issue type, file path, line range, verbatim quote, why it is wrong, and remediation. include only issues that pass all 21 prior steps.
if a loader returns null: pass. a loader may legitimately return null for an optional resource. flag only if the code should throw new Response(null, { status: 404 }) instead (i.e. a missing required resource).
if a useLoaderData call uses the annotation form <typeof loader>(): pass. this is the documented safe pattern and feeds SerializeFrom<T> automatically. do not flag it as an "unsafe cast."
if a bare new Response(body, init) is returned from a loader or action: pass. remix v2 routes may return any Response. json() is an ergonomic wrapper, not a requirement. non-JSON bodies (binary, streams, text) correctly skip json().
if both return redirect(...) and throw redirect(...) appear in actions: pass. both are legal in v2 actions. throwing is required only from non-action helpers when you want to exit the calling function early.
if a parent loader is revalidated after an unrelated child action: pass. this is default remix behavior. flag only if shouldRevalidate exists and explicitly prevents this when it should not.
if an action returns json({ errors }, { status: 400 }) on validation failure: pass. this is the canonical pattern for keeping the form route rendered with field errors. not the same as "no redirect on success" (which applies to successful mutations).
if useRevalidator().revalidate() is called after a focus event, polling interval, or websocket message: pass. these are documented use cases. flag only if the call immediately follows a <Form> or fetcher.submit() that remix already revalidates.
if Date is typed as string or Map is typed as {} after a loader return: pass. this is correct wire-format behavior due to SerializeFrom, not a typing bug.
if a useEffect loads data that is a browser-only API read (e.g. localStorage, window.innerHeight, IntersectionObserver): pass. loaders run server-side and cannot access browser globals. these belong in useEffect.
if the reviewer cannot determine whether a field is sensitive without domain knowledge (e.g. is_admin, user_tier): ask. flag as "possibly sensitive" with a note that domain review is needed.
findings are reported as a list of issues. each issue includes:
app/routes/admin.users.$id.tsx).lines 12-18 or line 45).issues are listed in file order, then line order within each file.
the review is complete when:
useLoaderData, useActionData, useNavigation, useFetcher, or useRevalidator has been examined.a passing review has zero findings. a review with findings includes a summary of affected patterns (e.g. "3 loaders leak password hashes; 2 actions lack validation; 1 useTransition holdover remains").
these are correct remix v2 usage patterns. do not report them as issues:
useEffect for client-only data , loaders run server-side; localStorage, window dimensions, IntersectionObserver, and browser-only APIs belong in useEffect. this is not a "missing loader" issue.loader returning null , a loader may legitimately return null for an optional resource. flag only if it should be a 404 throw.useLoaderData<typeof loader>() as type annotation , the <typeof loader> is a generic parameter feeding SerializeFrom<T>, not an as-style assertion. do not flag it as "unsafe cast."new Response(body, init) returns , v2 routes may return any Response. json() is ergonomic, not required. non-JSON bodies (binary, text, streams) correctly skip json().return redirect(...) from an action , both return redirect(...) and throw redirect(...) are legal in actions. throwing is required only from non-action helpers to exit the calling function.loader declared without the request arg , loaders may destructure only what they need ({ params }, { context }, or no args). unused args are not a bug.loader revalidated after an unrelated action , this is default remix behavior. flag only if shouldRevalidate exists and blocks this when it should not.json({ errors }, { status: 400 }) , this is the canonical validation-error pattern (keeps the form rendered with field errors). not the same as "no redirect on success."useRevalidator for focus, polling, or cross-tab sync , these are documented use cases. flag only if called immediately after a <Form> or fetcher.submit() that remix already revalidates.SerializeFrom-induced type changes , Date typed as string, Map typed as {} after deserialization is correct wire-format behavior, not a typing bug.flag these issues only when the specific context applies:
| issue | flag only if |
|---|---|
missing loader (using useEffect instead) |
data is available server-side and is NOT a browser-only API read |
loader returns a raw ORM object |
the object contains fields a reviewer would not paste into a screenshot (passwords, tokens, internal flags) |
action returns json on success |
the action is invoked via <Form> causing a URL change , NOT via useFetcher |
| missing pending ui | no nav.state / fetcher.state reference exists elsewhere in the file driving the same surface |
shouldRevalidate returns false |
the body has no condition or never references formAction / currentParams / nextParams |
manual useRevalidator().revalidate() |
the call follows a remix-managed mutation (<Form> post, fetcher.submit) , not focus, polling, websocket |
defer() used |
every promise in the defer({...}) payload was already awaited before the call |
these are hard gates. do not write user-facing findings until every gate passes for the batch you are about to report.
gate 1: location evidence , each issue lists the repo path to the route module and either a line range or a short verbatim quote from the file you read (not memory or diff-only). loader/action issues without a path to the export async function are not reportable.
gate 2: exemption check , for each issue, state in one line why it is not covered by valid patterns. confirm useEffect is not loading client-only data; confirm a bare Response return is not intentionally non-JSON; confirm a loader returning null is not a legitimate optional read.
gate 3: type-annotation vs type-assertion check , before flagging an "unsafe cast" on loader/action consumption, confirm the code uses as (assertion), not useLoaderData<typeof loader>() (annotation) or useActionData<typeof action>() (annotation). the generic form is the documented safe path and must not be flagged.
gate 4: v1 holdover check , before flagging "missing pending state," grep the file for useTransition, transition.submission, fetcher.type, formMethod === "post" or formMethod==='post' (lowercase, any whitespace/quote variation), and LoaderArgs / ActionArgs. if present, the finding is a v1-holdover migration issue, not a missing-feature issue. label it accordingly.
gate 5: pre-report verification , you have completed the pre-report verification checklist in beagle-core:review-verification-protocol for this review.
use this to triage issues during review:
loader, not useEffectactionrequest.formData() results are validated (zod/valibot/invariant) before useinternal_* fields)useLoaderData<typeof loader>() uses the type annotation form (not as Foo)throw a Response (or json/redirect), never a plain Error or stringredirect(...) (prg); validation failures return json({ errors }, { status: 400 })return nullparams.foo is checked with invariant/zod before useuseNavigation()/fetcher.state (no useTransition)formMethod comparisons use uppercase ("POST", not "post")shouldRevalidate returns defaultShouldRevalidate by default; opt-outs are narrow and justifieddefer() is used only when at least one promise streams (no await before passing it)<Await> is wrapped in <Suspense> and has an errorElementuseRevalidator().revalidate() is reserved for focus/polling/sse (not called immediately after a <Form> post or fetcher.submit)ask these before drafting findings:
loader, or is it stuck in a useEffect that defeats ssr and revalidation?password, token, internal_* fields) leak to the browser?request.formData() with a schema before touching the database?redirect(...) so refresh/back behaves correctly (prg)?useLoaderData<typeof loader>() (annotation) not useLoaderData() as Foo (assertion)?useTransition, transition.submission, transition.type, lowercase formMethod, LoaderArgs/ActionArgs)?shouldRevalidate return a literal false, or does it reach for defaultShouldRevalidate and opt out narrowly?defer() used only when at least one promise is passed unresolved, and is every <Await> wrapped in <Suspense> with an errorElement?loader body, return shape, params, throws, or sensitive-field leaks → references/loaders.mdaction body, formdata validation, success/error branches, or prg redirect → references/actions.mduseNavigation/useTransition migrations, shouldRevalidate, or useRevalidator use → references/revalidation.mddefer(), <Await>, <Suspense>, or streaming decisions → references/defer-await.md