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 [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 [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 → [remix-v2-data-flow](../remix-v2-data-flow/SKILL.md)
- Pre-report verification checklist → [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 [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.
this skill audits TypeScript route modules in a Remix v2 codebase for data-flow bugs that break SSR, revalidation, or security. use it when reviewing loader, action, shouldRevalidate, or headers exports, plus their consumers (useLoaderData, useActionData, useNavigation, useFetcher, useRevalidator, <Await>). the skill catches mutations in loaders, unvalidated FormData, leaked server fields, wrong redirect patterns, v1 holdovers (useTransition, LoaderArgs), and defer / <Suspense> mistakes that block streaming or hide errors.
app/routes/ exporting loader, action, shouldRevalidate, or headers; also consumer components importing useLoaderData, useActionData, useNavigation, useFetcher, useRevalidator, or <Await> from @remix-run/react@remix-run/node, @remix-run/cloudflare, @remix-run/deno) , inferred from importsidentify route modules in scope , locate all files under app/routes/ that export loader, action, shouldRevalidate, headers, or components consuming remix hooks. note the exact file path.
extract loader bodies , for each export async function loader or export const loader, read the full function body. check: data reads only (no mutations), params validated with invariant or schema, return value is a projected DTO (no password/token/internal fields), error cases throw Response (not strings or plain Error), 404 / auth short-circuits are explicit.
extract action bodies , for each export async function action or export const action, read the full function body. check: request.method handling (POST / PUT / DELETE / PATCH), request.formData() validated before use, success branch returns redirect(...) (PRG), validation errors return json({ errors }, { status: 400 }), both success and error paths are explicit (no silent return null).
check consumer hooks , in the component body, scan for useLoaderData, useActionData, useNavigation, useFetcher, useRevalidator. verify: type annotations use <typeof loader> / <typeof action> (not as Foo), pending state reads nav.state or fetcher.state (not v1 useTransition), formMethod comparisons are uppercase ("POST"), manual useRevalidator().revalidate() calls are not immediately after a <Form> post or fetcher.submit.
check shouldRevalidate exports , if present, verify: the function returns defaultShouldRevalidate by default, opt-outs are narrow and justified (e.g. conditional on formAction or currentParams), blanket return false is not used without reasoning.
check defer and streaming , if defer({...}) is used, verify: every promise in the payload was not awaited before passing it, every <Await> is wrapped in <Suspense>, every <Await> has an errorElement prop, no awaited data is passed to defer (antipattern).
run hard gates , before drafting findings, complete all five hard gates (location evidence, exemption check, type-annotation check, v1 holdover check, protocol verification). do not report issues that fail any gate.
cross-check with valid patterns , for each potential issue, consult the Valid Patterns (Do NOT Flag) section. confirm the code is not: useEffect reading browser-only APIs, bare Response returns for non-JSON, legitimate null loader returns, useLoaderData<typeof loader>() type annotations, return redirect(...) in actions, parent revalidation after unrelated actions, validation-error json() returns, useRevalidator for focus / polling, or SerializeFrom type changes.
verify context-sensitive rules , for issues in the Context-Sensitive Rules table, confirm the specific context applies before flagging (e.g. don't flag missing loader unless data is server-available and not browser-only).
compile findings , for each issue that passes all gates, write a finding with: repo path, line range or verbatim quote, rule violated, reason for the flag, and a reference to the applicable checklist item or reference document. output findings in order of severity (security > correctness > ergonomics).
loader: follow step 2 (extract and audit loader body). if the file does not, skip to step 3.action: follow step 3 (extract and audit action body). if the file does not, skip to step 4.useLoaderData, useActionData, useNavigation, useFetcher, or useRevalidator: follow step 4 (check consumer hooks). otherwise skip to step 5.shouldRevalidate: follow step 5 (check shouldRevalidate). otherwise skip to step 6.defer(): follow step 6 (check defer and streaming). otherwise skip to step 7.findings are structured as:
LOCATION: <repo-path-to-file>:<line-range-or-quote>
SEVERITY: [SECURITY | CORRECTNESS | ERGONOMICS]
RULE: [item from Review Checklist or Hard gates]
FINDING: <one-paragraph issue statement with concrete impact>
REFERENCE: [section from Valid Patterns or Context-Sensitive Rules or reference doc]
each finding must pass all hard gates (location evidence, exemption check, type-annotation check, v1 holdover check, protocol verification) before inclusion in the output. findings are ordered by severity. a successful review with no findings outputs:
RESULT: PASS
SCOPE: [file paths reviewed]
app/routes/ exporting loader, action, shouldRevalidate, or headers; components that consume useLoaderData, useActionData, useNavigation, useFetcher, useRevalidator, <Await>.<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.@remix-run/node (or @remix-run/cloudflare / @remix-run/deno) for server utilities; @remix-run/react for hooks and components.| Issue Type | Reference |
|---|---|
| mutations in loader, missing validation, leaked server fields, throwing primitives, missing param checks | references/loaders.md |
unvalidated FormData, json instead of redirect on success, missing error case, leaked actionData |
references/actions.md |
useTransition v1 holdover, missing pending state, blanket shouldRevalidate: false, misused useRevalidator |
references/revalidation.md |
defer for already-fast data, missing <Suspense>, no errorElement on <Await>, awaiting what should stream |
references/defer-await.md |
loader, not useEffectactionrequest.formData() results are validated (zod/valibot/invariant) before useinternal_* fieldsuseLoaderData<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 useTransitionformMethod 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 (Remix already revalidates)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."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.loader revalidated after an unrelated action , this is default Remix behavior, not a smell. flag only if shouldRevalidate exists and is wrong.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.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 awaited before the call |
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 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.
exemption check , pass: for each issue, you can state in one line why it is not covered by 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.
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.
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).
protocol , pass: you completed the pre-report verification checklist in review-verification-protocol for this review.
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.mdloader, 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, fetcher.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