
Remix V2 Data Flow Review
- 29 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-data-flow-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-data-flow-review
- AI & Agent Building
- AI-coding skill
Remix V2 Data Flow Review by the numbers
- 29 all-time installs (skills.sh)
- Ranked #9,376 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-data-flow-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 29 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Remix v2 Data Flow Code Review
Targets TypeScript route modules importing from @remix-run/*. See remix-v2-data-flow for canonical patterns.
Scope
- In scope: route modules under
app/routes/exportingloader,action,shouldRevalidate, orheaders; components that consumeuseLoaderData,useActionData,useNavigation,useFetcher,useRevalidator,<Await>. - Out of scope: form ergonomics (
<Form>markup, accessibility,useFetcherUI patterns) → covered byremix-v2-forms-review. Route module conventions, file naming, nested routing, error boundary placement → covered byremix-v2-routing-review. - Imports expected:
@remix-run/node(or@remix-run/cloudflare/@remix-run/deno) for server utilities;@remix-run/reactfor hooks and components.
Quick Reference
| 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 |
Review Checklist
- [ ] Data needed for first render is in
loader, notuseEffect - [ ] 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 (notas Foo) - [ ] 404 / auth short-circuits
throwaResponse(orjson/redirect), never a plainErroror string - [ ] Successful action returns
redirect(...)(PRG); validation failures returnjson({ errors }, { status: 400 }) - [ ] Action handles both success and error branches; no silent
return null - [ ]
params.foois checked withinvariant/ zod before use - [ ] Pending UI reads
useNavigation()/fetcher.state— nouseTransition - [ ]
formMethodcomparisons use UPPERCASE ("POST", not"post") - [ ]
shouldRevalidatereturnsdefaultShouldRevalidateby default; opt-outs are narrow and justified - [ ]
defer()is used only when at least one promise streams (noawaitbefore passing it) - [ ] Every
<Await>is wrapped in<Suspense>and has anerrorElement - [ ]
useRevalidator().revalidate()is reserved for focus/polling/SSE — not called immediately after a<Form>post orfetcher.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,windowdimensions,IntersectionObserver, and browser-only APIs belong inuseEffect. - `loader` returning `null` — A loader may legitimately return
null(e.g. optional resource not present); flag only if it should be a 404throw. - `useLoaderData<typeof loader>()` as type annotation — The
<typeof loader>is a generic parameter feedingSerializeFrom<T>, not aas-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 skipjson(). - `return redirect(...)` from an action — Both
return redirect(...)andthrow 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
shouldRevalidateexists 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 orfetcher.submitRemix would already revalidate. - `SerializeFrom`-induced type changes —
Datetyped asstring,Maptyped 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 awaited 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). 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 for this review.
When to Load References
- Reviewing a
loaderbody, return shape, params, throws, or sensitive-field leaks → references/loaders.md - Reviewing an
actionbody, FormData validation, success/error branches, or PRG redirect → references/actions.md - Reviewing
useNavigation/useTransitionmigrations,shouldRevalidate, oruseRevalidatoruse → references/revalidation.md - Reviewing
defer(),<Await>,<Suspense>, or streaming decisions → 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
- Pre-report verification checklist → review-verification-protocol
Before Submitting Findings
Complete Hard gates (especially gate 5), then report only issues that still pass the review-verification-protocol pre-report checks.
Action Review Reference
Anti-patterns and review prompts for export async function action in Remix v2 route modules. See remix-v2-data-flow for canonical action patterns.
1. Unvalidated FormData
Smell:
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const title = form.get("title") as string;
await db.project.create({ data: { title } });
return redirect("/projects");
}Why bad:
form.get("title")isFormDataEntryValue | null(string | File | null). Theas stringassertion hides thenullandFilecases.- Client-side validation is bypassable; the docs explicitly warn against trusting it.
- An attacker can submit empty strings, oversized payloads, or
Fileobjects where strings were expected. Schema validation is mandatory on the server.
Fix:
import { z } from "zod";
const NewProject = z.object({
title: z.string().min(1).max(120),
});
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const parsed = NewProject.safeParse(Object.fromEntries(form));
if (!parsed.success) {
return json(
{ errors: parsed.error.flatten().fieldErrors, values: Object.fromEntries(form) },
{ status: 400 },
);
}
const project = await db.project.create({ data: parsed.data });
return redirect(`/projects/${project.id}`);
}Object.fromEntries(formData) collapses repeated fields — for checkbox groups / multi-selects, use formData.getAll("tag") and feed it into the schema explicitly.
2. Returning json instead of redirect on success (broken PRG)
Smell:
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const project = await db.project.create({ data: { title: form.get("title") as string } });
return json({ project }); // success → no redirect
}Why bad: Without a redirect, the browser's URL stays on the form-submitting route. Pressing refresh re-POSTs the form (browser prompt "Confirm form resubmission?"), and the back button replays the mutation. This is the classic Post/Redirect/Get problem — Remix actions are designed to redirect on success.
Fix: return redirect(\/projects/\${project.id}\); on success. Keep return json(...) only for the validation-error branch where you want the user to stay on the form.
Do not flag when:
- The action is invoked via
useFetcherand the route was never navigated to (fetcher actions do not change the URL, so PRG is not at stake). - The action intentionally returns optimistic / interim data for an inline-edit UI consumed via
fetcher.data— the URL never changed and refresh has no meaning.
3. Missing error branch / silent return null
Smell:
export async function action({ request }: ActionFunctionArgs) {
try {
const form = await request.formData();
await db.project.create({ data: { title: form.get("title") as string } });
return redirect("/projects");
} catch {
return null; // swallowed
}
}Why bad: useActionData() becomes undefined, the UI shows no feedback, and the user retries blindly. ErrorBoundary cannot catch a returned value — only thrown ones.
Fix: Either let exceptions propagate (so ErrorBoundary handles them) or return a structured error:
return json(
{ errors: { _form: "Could not create project. Please try again." } },
{ status: 500 },
);For "this should never happen" cases: throw json({ message }, { status: 500 }).
4. Leaking server-only fields in actionData
Smell:
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const user = await db.user.findUnique({ where: { email: form.get("email") as string } });
if (!user) return json({ error: "Not found" }, { status: 404 });
return json({ user }); // ships passwordHash, etc.
}Why bad: Action return values ship to the client just like loader return values. Returning a raw ORM record exposes password hashes, session tokens, API keys, and internal_* flags via useActionData.
Fix: Project to a safe DTO before returning:
return json({ user: { id: user.id, email: user.email, name: user.name } });Field-name red flags to grep for in action return values: password, passwordHash, apiKey, secret, token, internal_, __, salt, mfaSeed, webhookSecret, csrfSecret.
5. Manual fetch() to a Remix route from a component
Smell:
function StarButton({ id }: { id: string }) {
return (
<button
onClick={() =>
fetch(`/projects/${id}/star`, { method: "POST" })
}
>
Star
</button>
);
}Why bad: Bypasses automatic revalidation, pending state (fetcher.state), progressive enhancement, and the CSRF / cookie flow built into <Form> and useFetcher. Errors are not surfaced via useActionData.
Fix: Use useFetcher().submit() or <fetcher.Form>:
const fetcher = useFetcher<typeof action>();
return (
<fetcher.Form method="post" action={`/projects/${id}/star`}>
<button type="submit">Star</button>
</fetcher.Form>
);6. ActionArgs v1 type holdover
Smell: import type { ActionArgs } from "@remix-run/node";
Why bad: v2 renamed ActionArgs → ActionFunctionArgs. Old name may exist as a deprecated alias — flag during v2 review.
Fix: import type { ActionFunctionArgs } from "@remix-run/node";
7. actionData from the wrong route
Smell: A parent layout reads useActionData() expecting results from a child route's action.
Why bad: useActionData "cannot access data from other parent or child routes." The hook is scoped to the route module it is called from; results from a different route's action are unreachable here.
Fix: Lift the action to the parent route, or use useFetcher with a shared key so multiple components see the same fetcher state.
Review prompts
- Is every
formData.get(...)value validated by a schema before reaching the DB? - Does the success branch end with
redirect(...)(unless the action is auseFetchertarget)? - Does the catch / failure branch return structured
json({ errors }, { status })or throw — never silently returnnull? - Are any object literals in
return json(...)derived from full ORM records without field projection? - Does the file still import
ActionArgsfrom@remix-run/node? - Is there a manual
fetch("/route", { method: "POST" })that should be auseFetcher?
Defer & Await Review Reference
Anti-patterns and review prompts for defer(), <Await>, and <Suspense> in Remix v2. See remix-v2-data-flow for canonical streaming patterns.
1. defer for already-fast data
Smell:
export async function loader({ params }: LoaderFunctionArgs) {
const product = db.product.findUnique({ where: { id: params.id! } }); // ~5ms query
return defer({ product });
}Why bad: defer exists to let the page render before slow data is ready. Streaming has overhead: an unresolved chunk, <Suspense> boundary work, an extra wire round-trip for the chunk. For sub-50ms queries this is pure overhead with no perceived-latency win — and it forces the consumer to add <Suspense> + <Await> for nothing.
Fix: Await it and return via json:
const product = await db.product.findUnique({ where: { id: params.id! } });
return json({ product });Rule of thumb: Use defer when (a) at least one promise is materially slower than the page's critical path and (b) the page can render usefully without it. Single-query routes should almost never defer.
2. Awaiting in the loader what should be deferred
Smell:
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.product.findUnique({ where: { id: params.id! } });
const reviews = await db.review.findMany({ where: { productId: params.id! } }); // slow!
return defer({ product, reviews });
}Why bad: await-ing the slow promise before constructing defer defeats the entire purpose — nothing streams. The page waits on reviews exactly as it would with json. defer only streams promises that are passed unresolved.
Fix: Drop the await on the slow query so the promise itself flows through defer:
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.product.findUnique({ where: { id: params.id! } });
const reviews = db.review.findMany({ where: { productId: params.id! } }); // no await
return defer({ product, reviews });
}The component reads reviews as a promise via useLoaderData<typeof loader>() and renders it inside <Suspense><Await>.
3. <Await> without a surrounding <Suspense>
Smell:
return (
<>
<ProductHeader product={product} />
<Await resolve={reviews}>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</>
);Why bad: <Await> suspends while its promise is pending. Without a <Suspense> ancestor, React has nothing to render as fallback and the page crashes. The docs say <Await> "must be rendered inside of a <React.Suspense> or <React.SuspenseList> parent."
Fix:
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews}>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</Suspense>4. Missing errorElement on <Await>
Smell:
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews}>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</Suspense>Why bad: If the deferred promise rejects (DB error, timeout), the rejection bubbles up to the route's ErrorBoundary and replaces the entire page with the error UI. The whole point of streaming was to keep the rest of the page useful while one section loads — a missing errorElement throws that away.
Fix: Provide an inline error fallback so only the slow section degrades:
<Suspense fallback={<ReviewsSkeleton />}>
<Await
resolve={reviews}
errorElement={<p role="alert">Could not load reviews.</p>}
>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</Suspense>5. defer returning sensitive fields
Smell:
return defer({ user: db.user.findUnique({ where: { id } }) });Why bad: Deferred promises serialize to the client the same way json returns do — the resolved value is streamed as JSON. Returning a raw ORM record exposes password hashes, API keys, and internal_* fields once the promise resolves.
Fix: Project to a DTO inside the promise chain so the resolved shape is safe:
return defer({
user: db.user
.findUnique({ where: { id } })
.then((u) => u && { id: u.id, email: u.email, name: u.name }),
});Review prompts
- Is every
defer({ ... })call accompanied by at least one promise passed withoutawait? - For every
awaitinside a loader that returnsdefer, is the awaited promise on the critical path (fast, must be ready before render)? - Is every
<Await resolve={...}>wrapped in a<Suspense fallback={...}>ancestor? - Does every
<Await>declare anerrorElementso promise rejection degrades only that section? - Do deferred promises resolve to DTO shapes, or do they resolve to raw ORM records with sensitive fields?
- Could the route be simplified to
json(...)because no single query is slow enough to justify streaming?
Loader Review Reference
Anti-patterns and review prompts for export async function loader in Remix v2 route modules. See remix-v2-data-flow for canonical loader patterns.
1. useEffect data fetching that belongs in a loader
Smell:
// app/routes/invoices.tsx
export default function Invoices() {
const [invoices, setInvoices] = useState<Invoice[]>([]);
useEffect(() => {
fetch("/api/invoices").then((r) => r.json()).then(setInvoices);
}, []);
return <InvoiceList invoices={invoices} />;
}Why bad: Defeats SSR, opens a fetch waterfall, skips automatic revalidation after actions, and breaks progressive enhancement. The docs say "Remix will call your loaders for you; in no case should you ever try to call your loader directly."
Fix: Move into a loader and read with useLoaderData<typeof loader>().
Do not flag when: the fetch reads localStorage, window.matchMedia, IntersectionObserver, or any browser-only API — those legitimately stay in useEffect.
2. Mutations inside a loader
Smell:
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
await db.session.update({ where: { id: user.sessionId }, data: { lastSeen: new Date() } });
return json({ user });
}Why bad: Loaders run on every GET navigation and speculatively on prefetch and during automatic revalidation after any action on the page. A write in a loader replays unpredictably and corrupts data.
Fix: Move the write into an action or a non-route server module triggered by an explicit <Form method="post"> / fetcher.submit(). Read-only logging that must live with the GET (e.g. analytics ping) belongs in a fire-and-forget call on the server response, not a synchronous await in the loader.
3. Missing FormData / params validation
Smell:
export async function loader({ params }: LoaderFunctionArgs) {
const project = await db.project.findUnique({ where: { id: params.id } });
return json({ project });
}Why bad: params.id is string | undefined. Prisma silently passes undefined, returning an unintended record or null. Downstream code crashes on .toLowerCase() etc.
Fix:
import invariant from "tiny-invariant";
export async function loader({ params }: LoaderFunctionArgs) {
invariant(params.id, "id required");
const project = await db.project.findUnique({ where: { id: params.id } });
if (!project) throw new Response("Not Found", { status: 404 });
return json({ project });
}Or parse params with a zod schema for slug/id format validation.
4. Leaking server-only fields to the client
Smell:
export async function loader({ request }: LoaderFunctionArgs) {
const user = await db.user.findUnique({
where: { id: await getUserId(request) },
});
return json({ user }); // ships passwordHash, apiKey, internalRole, etc.
}Why bad: Everything returned from a loader travels to the browser as JSON. Password hashes, API keys, session tokens, and internal_* flags become visible in the Network panel and the SSR HTML payload.
Fix: Project to a safe DTO before returning:
return json({
user: { id: user.id, email: user.email, name: user.name },
});Field-name red flags to grep for in loader return values: password, passwordHash, apiKey, secret, token, internal_, __, salt, mfaSeed, webhookSecret.
5. Wrong type assertion vs type annotation
Smell:
const data = useLoaderData() as { invoices: Invoice[] };Why bad: An as assertion bypasses SerializeFrom<T>. The wire format collapses Date → string, Map/Set → {}, strips undefined, and removes class methods. The assertion will lie about the runtime shape.
Fix:
const { invoices } = useLoaderData<typeof loader>();The <typeof loader> here is a generic parameter (type annotation), not an as-style assertion. Do not flag the annotation form as an "unsafe cast" — it is the documented safe path.
6. Throwing primitives instead of Response
Smell:
export async function loader({ params }: LoaderFunctionArgs) {
const project = await db.project.findUnique({ where: { id: params.id! } });
if (!project) throw new Error("Not Found"); // or: throw "not found"
return json({ project });
}Why bad: useRouteError() + isRouteErrorResponse() only classify thrown Response objects as route responses. A plain Error hits the boundary as an unknown runtime error with no status / statusText; a thrown string is even worse.
Fix:
if (!project) throw new Response("Not Found", { status: 404 });
// or: throw json({ message: "Not found" }, { status: 404 });For auth guards, throw redirect("/login") from a helper short-circuits the loader cleanly.
7. LoaderArgs v1 type holdover
Smell:
import type { LoaderArgs } from "@remix-run/node";
export async function loader({ request }: LoaderArgs) { ... }Why bad: v2 renamed LoaderArgs → LoaderFunctionArgs. The old name may exist as a deprecated alias but is a migration smell — flag it during a v2 review.
Fix: import type { LoaderFunctionArgs } from "@remix-run/node";
8. Re-defining the same data via parent + child loaders
Smell: Both app/routes/projects.tsx and app/routes/projects.$id.tsx independently call db.project.findMany.
Why bad: Two sources of truth, doubled DB queries, divergent shapes after revalidation.
Fix: Load once at the highest matching route and read via useRouteLoaderData("routes/projects") in children.
Review prompts
- Is every
params.xaccess guarded byinvariantor a schema parse? - Does the loader return any object that originates from an ORM
findUnique/findManywithout explicit field projection? - Is anything in the returned object named after a secret (
password,token,apiKey,internal_)? - Are 404 / auth short-circuits using
throw new Response(...)orthrow redirect(...)(notthrow new Error(...))? - Is there a
useEffectin the default export that fetches non-browser-API data? - Does the file still import
LoaderArgsfrom@remix-run/node?
Revalidation & Pending State Review Reference
Anti-patterns and review prompts for useNavigation, useTransition (v1 holdover), shouldRevalidate, and useRevalidator in Remix v2. See remix-v2-data-flow for canonical patterns.
1. useTransition — v1 holdover
Smell:
import { useTransition } from "@remix-run/react";
function SaveButton() {
const transition = useTransition();
const busy = transition.state === "submitting";
// ...
}Why bad: useTransition was removed in Remix v2; the hook is now useNavigation. The submission object was flattened — transition.submission.formMethod no longer exists; the fields are on the root: nav.formMethod, nav.formData, nav.formAction.
Fix:
import { useNavigation } from "@remix-run/react";
function SaveButton() {
const nav = useNavigation();
const busy = nav.state !== "idle" && nav.formMethod === "POST";
}Related holdovers to flag in the same review:
fetcher.type === "actionSubmission"—fetcher.typeis removed. Branch onfetcher.stateplus presence offetcher.formData.formMethod === "post"(lowercase) — v2 returns UPPERCASE ("POST","GET","DELETE"); lowercase comparisons silently never match. Applies touseNavigation,useFetcher, and theshouldRevalidatearg.LoaderArgs/ActionArgs— renamed toLoaderFunctionArgs/ActionFunctionArgs.
2. Missing pending state
Smell: A <Form method="post"> submit button has no disabled / spinner / busy attribute. Users double-click and double-submit.
Why bad: Long submits feel broken; double submits create duplicate records. Remix exposes nav.state and fetcher.state for exactly this.
Fix:
const nav = useNavigation();
const busy = nav.state !== "idle" && nav.formMethod === "POST";
return <button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</button>;For useFetcher-driven mutations, gate on fetcher.state !== "idle" instead. POST flow goes idle → submitting → loading → idle; GET flow goes idle → loading → idle — a spinner gated only on "submitting" will miss GET forms.
Do not flag when:
- The button uses CSS /
data-busyattribute hooked intonav.stateelsewhere (search the file fornav.state/fetcher.statebefore flagging "missing pending state"). - The form posts to a
useFetcherthat drives optimistic UI fromfetcher.formData— the optimistic state is the pending indicator.
3. Blanket shouldRevalidate returning false
Smell:
export const shouldRevalidate: ShouldRevalidateFunction = () => false;Why bad: The route now never revalidates — not after the user's own mutations on this route, not on params change, not when explicit useRevalidator() calls fire. The docs warn: "This makes it possible for your UI to get out of sync with your server if you do it wrong, so be careful." Users will see stale data after their own actions and not understand why.
Fix: Start from defaultShouldRevalidate and opt out only for the narrow conditions that justify it:
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentParams,
nextParams,
defaultShouldRevalidate,
}) => {
// Root loader carries static env vars; only revalidate if params change (they shouldn't here).
if (currentParams.userId !== nextParams.userId) return true;
return false;
};Legitimate `return false`: A root loader carrying purely static data ({ env: { APP_URL } }) that never changes for the page lifetime. Even there, prefer narrowing on formAction rather than a blanket false.
Red flags to look for in shouldRevalidate bodies:
- Return value is a literal
falsewith no condition. - The function body never references
formAction,formMethod,currentParams, ornextParams. - The function body never references
defaultShouldRevalidate.
4. useRevalidator when navigation would do
Smell:
function SaveButton() {
const fetcher = useFetcher();
const { revalidate } = useRevalidator();
return (
<button
onClick={async () => {
await fetcher.submit({ ... }, { method: "POST", action: "/save" });
revalidate(); // manual refresh
}}
>
Save
</button>
);
}Why bad: After any action submitted via <Form> or useFetcher, Remix automatically revalidates all loaders for matching routes on the page. The manual revalidate() call duplicates the loader requests and races the automatic pass. The docs say: "If you find yourself using this for normal CRUD operations on your data… you're probably not taking advantage of the other APIs like <Form>, useSubmit, or useFetcher."
Fix: Remove the revalidate() call. useRevalidator is for cases the framework cannot trigger automatically: cross-tab sync, focus-driven refresh, polling, websocket-pushed updates.
Legitimate uses (do not flag):
const { revalidate, state } = useRevalidator();
useEffect(() => {
function onFocus() {
if (state === "idle") revalidate();
}
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [revalidate, state]);5. Polling with useRevalidator without guards
Smell:
useEffect(() => {
const id = setInterval(() => revalidate(), 5000);
return () => clearInterval(id);
}, [revalidate]);Why bad: Multiple revalidations can stack on top of each other — if the loader takes 6 seconds, a second call fires while the first is still in flight. Across many concurrent users this duplicates DB queries and can hammer the origin.
Fix: Gate on state === "idle", add jitter, pause when the tab is hidden:
useEffect(() => {
const id = setInterval(() => {
if (state === "idle" && document.visibilityState === "visible") revalidate();
}, 5000 + Math.random() * 1000);
return () => clearInterval(id);
}, [revalidate, state]);Review prompts
- Does the file import
useTransitionfrom@remix-run/react? - Are there comparisons against lowercase
"post"/"get"/"delete"? - Does any
fetcher.type === ...switch survive? - Does
shouldRevalidatealways return a literalfalse? - Does any submit button lack a
disabledor busy class tied tonav.state/fetcher.state? - Is
revalidate()called manually right after afetcher.submit/<Form>post that Remix would already revalidate? - Are polling
revalidate()calls gated onstate === "idle"and visibility?