
Remix V2 Data Flow
- 28 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-data-flow is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-data-flow
- AI & Agent Building
- AI-coding skill
Remix V2 Data Flow by the numbers
- 28 all-time installs (skills.sh)
- Ranked #9,505 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-flowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| 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
Quick Reference
Loader + typed read:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
const invoices = await db.invoice.findMany();
return json({ invoices });
}
export default function Invoices() {
// typeof loader is a type ANNOTATION (not assertion) — drives SerializeFrom<T>.
const { invoices } = useLoaderData<typeof loader>();
return <InvoiceList invoices={invoices} />;
}Action + redirect-after-success (PRG):
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { useActionData, Form } from "@remix-run/react";
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 }, { status: 400 });
const project = await db.project.create({ data: parsed.data });
return redirect(`/projects/${project.id}`);
}Canonical APIs
Route modules export loader / action; components read results via useLoaderData<typeof loader>() and useActionData<typeof action>(). After every action, Remix automatically revalidates the loaders of all matching routes on the page, so the UI stays consistent with the server without manual cache invalidation.
Signatures:
loader: ({ request, params, context }: LoaderFunctionArgs) => Response | Promise<Response>— server-only read, runs on SSR and on client navigations via fetch.action: ({ request, params, context }: ActionFunctionArgs) => Response | Promise<Response>— server-only handler for non-GET requests (POST/PUT/PATCH/DELETE).json(data, init?: number | ResponseInit): TypedResponse<typeof data>— ergonomic JSONResponsewrapper with status/headers.redirect(url, init?: number | ResponseInit): TypedResponse<never>— 30x response; default 302.
Imports: @remix-run/node for server utilities (json, redirect, defer, type args) on Node; substitute @remix-run/cloudflare or @remix-run/deno for those targets. Hooks and components come from @remix-run/react.
Type Annotations, Not Assertions
useLoaderData<typeof loader>() is a type annotation, not a as-style assertion. The generic feeds SerializeFrom<typeof loader>, which models the wire-format transformation: Date becomes string, Map/Set collapse, undefined fields are stripped, class methods vanish. If you call data.createdAt.getFullYear() on a Date field, that's a runtime bug — the type already says string.
When json() Is Optional in v2
v2 did not change the underlying contract: loaders and actions must return a Response. json() is the ergonomic wrapper that sets application/json and lets you supply status / headers. Bare object returns work in v2 (Remix auto-wraps as json()), but json() is preferred for explicit status, headers, and clean TypedResponse<T> typing. Reach for json() whenever you need:
- A non-200 status code (e.g.
{ status: 400 }for validation errors). - Custom headers (caching,
Set-Cookie). - An explicit
TypedResponse<T>for cleanuseLoaderData<typeof loader>()inference.
Throwing for Short-Circuits
Throwing a Response from a loader or action exits the data function immediately. Use this for auth guards (throw redirect("/login")) and 404s (throw new Response("Not Found", { status: 404 }) or throw json({ message }, { status: 404 })). Throwing a plain Error will not be classified as a route response by useRouteError() / isRouteErrorResponse().
Streaming Rejections: <Await errorElement> + useAsyncError()
When a promise passed through defer() rejects, an <Await errorElement={...}> boundary catches it inline — without it, the rejection bubbles to the route's ErrorBoundary and tears down the whole page, defeating the streaming benefit. Inside the errorElement, call useAsyncError() (from @remix-run/react) to read the rejection value — this is the streaming analogue of useRouteError().
function ReviewsError() {
const error = useAsyncError(); // typed as `unknown`
return <p>Failed to load reviews: {String(error)}</p>;
}
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>Sensitive Data
Everything returned from a loader travels to the browser as JSON. Project to a safe DTO ({ id, email, name }) before returning; never return the full Prisma User, password hashes, API keys, or internal flags. Loaders execute server-only — but the return value is shipped to the client wholesale.
Mutations Belong in Actions
Loaders run on every GET navigation and may be invoked speculatively by prefetch; they also re-run during automatic revalidation. Anything that mutates persistent state must live in action, reached via <Form method="post"> or useFetcher. Calling fetch() directly from a component to hit a Remix route bypasses revalidation, pending state, and progressive enhancement — use useFetcher().submit() / useFetcher().load() instead.
Routing Gotchas to Remember
- Only the deepest matching action runs. Index routes nested under a layout collide unless you target them with
<Form action="/things?index" method="post" />. - `useActionData` is scoped to the current route. It cannot access action results from parent or child routes; to share, lift the action or use a
useFetcherwithkey. - Headers from the leaf loader win. Remix only uses the deepest matching
headersexport. Parent caching policies are ignored unless you explicitly mergeparentHeaders. - Default revalidation revalidates ALL routes on the page after an action — even those whose params didn't change. Use
shouldRevalidateto opt out of expensive parent loaders.
Pending State (v1 → v2)
useTransition is removed in v2 — use useNavigation. The submission object is flattened directly onto the navigation in v2 (and the fetcher likewise; both nav.formData/nav.formMethod and fetcher.formData/fetcher.formMethod are flat). formMethod is now UPPERCASE in v2 ("POST", not "post"); comparisons like nav.formMethod === "post" silently never match. fetcher.type is also gone — branch on fetcher.state plus presence of fetcher.formData.
GET submissions go idle → loading → idle. POST flow goes idle → submitting → loading → idle. Spinners gated only on "submitting" will miss GET forms. GET submissions still populate nav.formData and nav.formMethod === "GET" during the loading phase, so filter-form pending UI should branch on formData presence, not on state === 'submitting'. For useFetcher, submitting applies to BOTH GET (<fetcher.Form method='get'> and fetcher.submit(..., {method:'get'})) and non-GET; only fetcher.load() skips submitting. This is the inverse of useNavigation, which skips submitting for GET.
Gates (decision sequencing)
Answer in order. Pass means the condition is true; pick the API on the same line and stop.
loader vs useEffect
1. Is the data needed for correct first render of this route (SSR, prefetch, automatic revalidation after actions)?
- Pass →
loader+useLoaderData<typeof loader>(). Stop. - Fail → Step 2.
2. Is the fetch driven by post-mount user interaction, timer, or subscription (not route entry)?
- Pass →
useEffect/ event handlers. Stop. - Fail → Prefer loader + revalidation; do not mirror navigation inside an effect.
json() vs raw Response vs defer()
1. Do any returned fields need to stream (slow query, expensive aggregation) while the page renders fast?
- Pass →
defer({ critical: await…, slow: promiseWithoutAwait })+<Suspense><Await>…</Await></Suspense>. Stop. - Fail → Step 2.
2. Do you need a custom status code, custom headers, or explicit `TypedResponse<T>` typing?
- Pass →
json(data, init)(orredirect(url, init)for 3xx). Stop. - Fail → Step 3.
3. Do you need a non-JSON body (binary, plain text, streamed file)?
- Pass → Build a raw
new Response(body, init). Stop. - Fail → Default to
json(data)— it's the documented v2 contract for object payloads.
<Form> / route action vs useFetcher
1. Should the URL or history stack change (bookmark / share / back returns to prior screen)?
- Pass →
<Form method="post">posting to a routeaction. Stop. - Fail → Step 2.
2. Mutation stays on the same route (inline edit, list-row toggle, popover, optimistic UI)?
- Pass →
useFetcher()/fetcher.Form/fetcher.submit(). Stop.
Additional Documentation
- Loaders: See references/loaders.md for
loadersignature, typeduseLoaderData,json()vs rawResponse,redirect(), throwing, sensitive-data filtering, params handling. - Actions: See references/actions.md for
actionsignature, FormData parsing,useActionData<typeof action>(), zod/valibot validation, redirect-after-success. - Defer & Await: See references/defer-await.md for
defer()+<Await>+<Suspense>, when streaming helps TTFB, error handling. - Revalidation & Pending State: See references/revalidation.md for automatic revalidation,
shouldRevalidate,useRevalidator,useNavigation(plus v1useTransitionrename).
v1 → v2 Quick Diff
| Concern | v1 | v2 |
|---|---|---|
| Navigation hook | useTransition() | useNavigation() |
| Submission shape | transition.submission.formMethod | flat nav.formMethod / nav.formData |
formMethod casing | "post" | "POST" (UPPERCASE) |
| Fetcher type field | fetcher.type === "actionSubmission" | branch on fetcher.state + fetcher.formData |
| Loader args type | LoaderArgs / ActionArgs | LoaderFunctionArgs / ActionFunctionArgs |
| Returning data | json(data) required | json(data) still the documented contract |
Actions
Actions are server-only handlers for non-GET requests (POST, PUT, PATCH, DELETE) targeting a route. They are where data mutations live. Actions and loaders co-locate in the same route module: "loader = read, action = write."
Signature
import { type ActionFunctionArgs } from "@remix-run/node";
export async function action(
{ request, params, context }: ActionFunctionArgs,
): Promise<Response> {
// ...
}Deprecated alias still exported by @remix-run/node in 2.x — prefer the v2 name (LoaderFunctionArgs / ActionFunctionArgs).
Parsing FormData
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const title = form.get("title");
// ...
}request.formData() returns a standard FormData. Every value is FormDataEntryValue (string | File) — never trust the static type, always validate. Type assertions like form.get("title") as string hide injection and type-confusion bugs.
Validation with zod (or valibot)
Server-side validation is mandatory; client validation can always be bypassed.
// app/routes/projects.new.tsx
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { useActionData, Form } from "@remix-run/react";
import { z } from "zod";
const NewProject = z.object({
title: z.string().min(1).max(120),
description: z.string().max(2000).optional(),
});
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}`);
}Two important moves above:
1. Return a 400 on validation failure so the form route still renders and the user sees inline errors. 2. Echo `values` back so the form can rehydrate after a failed submit.
valibot follows the same shape; substitute safeParse / flatten() equivalents per its API.
Typed useActionData
export default function NewProject() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="title" defaultValue={(actionData?.values?.title as string) ?? ""} />
{actionData?.errors?.title && <p role="alert">{actionData.errors.title}</p>}
<button type="submit">Create</button>
</Form>
);
}useActionData<typeof action>() returns SerializeFrom<typeof action> | undefined. The same serialization rules from loaders apply (Dates become strings, undefined is stripped). It is scoped to the current route — it cannot read data from parent or child actions. To share an action result up or down the tree, lift the action to a higher route or use a useFetcher with a key.
Redirect-After-Success (PRG)
The Post/Redirect/Get pattern is the v2 default for successful mutations: an action that returns a redirect() causes the browser to navigate to the target route, which prevents accidental re-submission on refresh and naturally surfaces the freshly mutated state.
const project = await db.project.create({ data: parsed.data });
return redirect(`/projects/${project.id}`);If you want to stay on the same route after success (e.g. an inline editor), return json({ ok: true, project }) instead — but then you own pending UI and reset logic via useActionData.
Error Returns vs Throws
| Outcome | Mechanism | Where it surfaces |
|---|---|---|
| Validation error | return json({ errors }, { status: 400 }) | useActionData<typeof action>() |
| Auth failure | throw redirect("/login") | Browser navigation |
| 404 / 403 | throw new Response(..., { status: 404 }) or throw json({...}, { status: 404 }) | ErrorBoundary via useRouteError() |
| Unexpected exception | Unhandled throw (any Error) | ErrorBoundary (no status field) |
An action that returns null / undefined with no error handling is an anti-pattern: useActionData() is undefined, errors are silently swallowed, and the user sees nothing. Either throw to the boundary or return json({ error }) and render it.
Index Route Actions
Only the deepest matching action runs. When a parent layout and its index child both define an action, target the index explicitly:
<Form action="/things?index" method="post" />Without ?index, the layout action wins.
Fetcher Actions (No Navigation)
For inline edits, list-row toggles, and optimistic UI, post to a route action without a URL change via useFetcher:
import { useFetcher } from "@remix-run/react";
export function StarButton({ project }: { project: Project }) {
const fetcher = useFetcher<typeof action>();
// Predict the next state from in-flight FormData for optimistic UI.
const starred = fetcher.formData
? fetcher.formData.get("starred") === "1"
: project.starred;
return (
<fetcher.Form method="post" action={`/projects/${project.id}/star`}>
<input type="hidden" name="starred" value={starred ? "0" : "1"} />
<button aria-pressed={starred}>{starred ? "★" : "☆"}</button>
</fetcher.Form>
);
}After the fetcher resolves, Remix automatically revalidates affected loaders.
File Uploads
request.formData() returns a File for <input type="file"> fields. For large uploads, prefer unstable_parseMultipartFormData with unstable_createFileUploadHandler or unstable_createMemoryUploadHandler from @remix-run/node — the bare request.formData() buffers the entire body in memory.
Don't Hit Actions With Manual fetch()
Calling fetch("/projects/123/star", { method: "POST" }) from a component bypasses revalidation, pending state, progressive enhancement, and CSRF affordances built into <Form> and useFetcher. Use useFetcher().submit() or useFetcher().load() instead — same UX without a URL change, and Remix wires up the lifecycle for you.
Pending State on Submit
import { useNavigation, Form } from "@remix-run/react";
export default function SaveButton() {
const nav = useNavigation();
// POST flow: idle → submitting → loading → idle. GET: idle → loading → idle.
// formMethod is UPPERCASE in v2.
const busy = nav.state !== "idle" && nav.formMethod === "POST";
return (
<Form method="post">
<button type="submit" disabled={busy}>
{busy ? "Saving…" : "Save"}
</button>
</Form>
);
}Missing pending UI is a real UX bug — users double-click and double-submit. Gate on nav.state !== "idle" (or fetcher.state !== "idle" for non-navigating mutations).
For useFetcher, submitting applies to BOTH GET (<fetcher.Form method='get'> and fetcher.submit(..., {method:'get'})) and non-GET; only fetcher.load() skips submitting. This is the inverse of useNavigation, which skips submitting for GET.
Action Anti-Pattern Recap
- No server-side validation — client validation is bypassable; always re-validate on the server with zod / valibot and return
{ status: 400 }on failure. - `form.get("title") as string` —
FormDataEntryValueisstring | File; type assertions hide injection bugs. Parse withObject.fromEntries(form)and a schema. - Returning `null` from an action —
useActionData()becomesundefinedand errors are silently swallowed. Eitherthrow json({ message }, { status: 500 })to hitErrorBoundaryorreturn json({ error }). - No redirect after success — re-rendering the same form route on success risks accidental re-submission on refresh. Redirect via PRG unless you have a specific reason to stay.
- Manual `fetch()` instead of `useFetcher` — bypasses revalidation, pending state, and progressive enhancement.
- Forgetting `?index` when posting from a layout to its index action — the layout action runs instead.
Cross-Reference
- Loader-side details: see loaders.md for
useLoaderData<typeof loader>()typing,redirect()semantics, and sensitive-data filtering — the same rules apply to action returns. - Pending UI and revalidation: see revalidation.md for
useNavigation,useFetcher,useRevalidator,shouldRevalidate, and the v1 → v2useTransitionrename.
Defer & Await — Streaming Loader Data
defer() lets a loader return a streamed/deferred response that may contain unresolved promises. The browser receives the critical data immediately; slow data resolves over the same HTTP connection and triggers a <Suspense> reveal when ready.
When Streaming Helps TTFB
Defer pays off when:
- One query dominates the loader's wall-clock time (e.g. an aggregation, a third-party API call, a slow report) and
- The rest of the page is useful on its own (header, nav, primary record).
Defer does not help when:
- All queries are fast (you just add Suspense overhead for no win).
- The slow query is the critical first paint (no useful UI without it).
- You return a deferred promise but
awaitit before constructingdefer()— that defeats streaming entirely (see anti-pattern below).
Signature
import { defer } from "@remix-run/node";
defer(data, init?: number | ResponseInit): TypedDeferredData;
// (numeric status shorthand was added in Remix 2.5+; pre-2.5 requires `defer(data, { status: 404 })`)data may contain plain values and unresolved promises in any field. Remix serializes the resolved values eagerly and streams the rest.
Canonical Pattern
// app/routes/product.$id.tsx
import { defer, type LoaderFunctionArgs } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderFunctionArgs) {
// Critical data is awaited so the page can't render meaningfully without it.
const product = await db.product.findUnique({ where: { id: params.id } });
// Slow data is NOT awaited — pass the raw promise so it can stream.
const reviews = db.review.findMany({ where: { productId: params.id } });
return defer({ product, reviews });
}
export default function ProductPage() {
const { product, reviews } = useLoaderData<typeof loader>();
return (
<>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<p>Failed to load reviews</p>}>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</Suspense>
</>
);
}Three rules — all required:
1. Don't await the slow promise before passing it to defer(). The whole point is to ship the unresolved promise. 2. Wrap every `<Await>` in a `<Suspense fallback={…}>`. React throws without a fallback boundary; there's nothing to render while the promise is pending. 3. Provide `errorElement` on every <Await> (next section).
Error Handling in <Await>
A rejected deferred promise without an errorElement bubbles to the nearest route ErrorBoundary — which kills the whole page instead of degrading just the slow section. Always provide an inline fallback:
<Suspense fallback={<ReviewsSkeleton />}>
<Await
resolve={reviews}
errorElement={<p role="alert">Reviews are temporarily unavailable.</p>}
>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</Suspense>If you want the error to reach a child ErrorBoundary instead of being swallowed inline, you can re-throw from a render-prop wrapper — but the default expectation is graceful, in-place degradation.
Reading the Deferred Value
useLoaderData<typeof loader>() returns the promise field as Promise<T> in the component. You never .then() it manually — <Await resolve={…}> handles the unwrap and re-render. The render prop receives the resolved value:
<Await resolve={reviews}>
{(rs) => <ProductReviews reviews={rs} />}
</Await>Inside the render prop, rs is fully typed via SerializeFrom — same serialization rules as a regular loader return (Dates become strings, etc.).
Anti-Patterns
- `await` before `defer`:
const reviews = await db.review.findMany(...); return defer({ product, reviews });makesdeferbehave exactly likejson— nothing streams. - `<Await>` without `<Suspense>`: React throws because there's no fallback.
- Missing `errorElement`: A single slow query failure tears down the whole route.
- Streaming everything: If every field is deferred, the user stares at fallbacks. Resolve the critical record synchronously; only defer the long tail.
Compatibility Notes
defer()requires a streaming-capable runtime adapter. Node (@remix-run/node) and Cloudflare (@remix-run/cloudflare) both support it. Some older serverless adapters buffer responses and break streaming — verify your deploy target supports HTTP streaming end-to-end before depending ondefer()for TTFB.defer()returns aTypedDeferredData<T>, not aTypedResponse. You cannot wrap adefer()result injson()or vice versa — pick one per loader.
When to Use Plain json() Instead
If the slow query is fast in practice (P95 < 100ms), or if the page is meaningless without it, just await it inside the loader and return json(). Defer adds a <Suspense> boundary and a render flicker — only worth it when the TTFB win is real.
Multiple Deferred Fields
You can stream more than one field; each <Await> resolves independently as its promise settles.
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 } });
const related = db.product.findRelated(params.id!);
return defer({ product, reviews, related });
}
export default function ProductPage() {
const { product, reviews, related } = useLoaderData<typeof loader>();
return (
<>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<p>Reviews unavailable.</p>}>
{(rs) => <ProductReviews reviews={rs} />}
</Await>
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<Await resolve={related} errorElement={<p>Related items unavailable.</p>}>
{(items) => <RelatedProducts items={items} />}
</Await>
</Suspense>
</>
);
}Interaction with Revalidation
After every action, Remix re-runs loaders for matching routes — including loaders that returned defer(). The new deferred promises replace the old ones, and <Await> will re-suspend until the new ones resolve. You don't need to invalidate manually; the same useLoaderData<typeof loader>() call picks up the fresh promises.
Picking the Right Tool
| Goal | API |
|---|---|
| One slow query, rest of page is useful immediately | defer() + <Await> |
| All queries fast, want simple data + types | json() |
| Need explicit status / headers / typing | json(data, init) |
| Auth guard / 404 short-circuit | throw redirect() / throw new Response(...) |
| Binary / streamed file body | Raw new Response(stream, init) |
Loaders
Loaders are server-only data-fetch functions exported from a route module. Remix calls them during SSR and on every client navigation that lands on the route. You never call a loader directly — "Remix will call your loaders for you; in no case should you ever try to call your loader directly."
Signature
import { type LoaderFunctionArgs } from "@remix-run/node";
export async function loader(
{ request, params, context }: LoaderFunctionArgs,
): Promise<Response> {
// ...
}request— standardRequest. Usenew URL(request.url)to read search params,request.headersfor cookies /Authorization.params— route params from the file/segment definition. Values are typedstring | undefined— always guard before passing to a DB query.context— adapter-supplied context (Cloudflare bindings, Express locals via the Node adapter, etc.).
Deprecated alias still exported by @remix-run/node in 2.x — prefer the v2 name (LoaderFunctionArgs / ActionFunctionArgs).
Typed useLoaderData
import { useLoaderData } from "@remix-run/react";
export default function Invoices() {
const { invoices, status } = useLoaderData<typeof loader>();
return <InvoiceList invoices={invoices} activeStatus={status} />;
}useLoaderData<typeof loader>() is a type annotation, not a as-style assertion. Internally it resolves to SerializeFrom<typeof loader>, which models the on-the-wire transformation:
Datebecomesstring.MapandSetboth serialize to{}(no own-enumerable entries).undefinedfields are stripped.- Class instances lose their methods (just plain data survives).
If your component calls data.createdAt.getFullYear(), the type already says string — that's a real bug, not a tooling complaint.
json() — Status, Headers, Typing
import { json } from "@remix-run/node";
return json({ invoices }, { status: 200, headers: { "Cache-Control": "private, max-age=10" } });
return json({ errors }, { status: 400 });
return json({ user }, 201); // numeric shorthand for statusjson(data, init?) is a shortcut for an application/json response with the given status and headers. The return type is TypedResponse<typeof data> — that's what lets useLoaderData<typeof loader>() infer the payload through SerializeFrom.
When json() Is Optional in v2
v2 did not change the underlying contract: loaders must return a Response. json() is the ergonomic wrapper. Bare object returns work in v2 (Remix auto-wraps as json()), but json() is preferred for explicit status, headers, and clean TypedResponse<T> typing. Reach for json() whenever you need a non-200 status, custom headers, or explicit TypedResponse<T> inference.
redirect()
import { redirect } from "@remix-run/node";
throw redirect("/login");
return redirect(`/projects/${project.id}`, {
headers: { "Set-Cookie": await commitSession(session) },
});redirect(url, init?) is a shortcut for 30x responses. Default status is 302; supports 301 / 303 / 307 and any standard Response init (including Set-Cookie).
Throwing for Short-Circuits
Throwing a Response exits the data function immediately — useful for auth guards and 404s.
// app/utils/auth.server.ts
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) throw redirect("/login");
return user;
}
// In a loader:
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
return json({ user });
}Throw new Response("Not Found", { status: 404 }) or throw json({ message }, { status: 404 }) for 404s — useRouteError() + isRouteErrorResponse() will then expose status / statusText to your ErrorBoundary. Throwing a plain Error will not classify as a route response.
Filtering Sensitive Data
Loaders run server-only, but their return values ship to the browser as JSON. Project to a safe DTO before returning.
// Bad — leaks passwordHash, internal flags
const user = await db.user.findUnique({ where: { id } });
return json({ user });
// Good
const user = await db.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
return json({ user });If you must fetch the full record (to do server-side work), strip before return:
const full = await db.user.findUnique({ where: { id } });
const { passwordHash, internalNotes, ...safe } = full!;
return json({ user: safe });Params: Always Guard
params values are string | undefined. Downstream DB lookups silently coerce or query undefined.
import invariant from "tiny-invariant";
export async function loader({ params }: LoaderFunctionArgs) {
invariant(params.projectId, "projectId required");
const project = await db.project.findUnique({ where: { id: params.projectId } });
if (!project) throw new Response("Not Found", { status: 404 });
return json({ project });
}Or parse with zod for richer shapes:
const ParamsSchema = z.object({ projectId: z.string().uuid() });
const { projectId } = ParamsSchema.parse(params);Reading Search Params
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const status = url.searchParams.get("status") ?? "open";
const invoices = await db.invoice.findMany({ where: { status } });
return json({ invoices, status });
}Don't Mutate in Loaders
Loaders run on every GET and may be invoked speculatively by prefetch; revalidation re-runs them after every action. Any mutation inside a loader will replay. Writes belong in action.
Don't Re-Define Data in Child Loaders
Re-loading the same query in a parent and child means two sources of truth, doubled DB load, and divergent shapes after revalidation. Load once at the highest matching route and read from children via useMatches / useRouteLoaderData.
.server Suffix
Modules with a .server.ts / .server.tsx suffix are never bundled to the client. Import DB clients, secrets, and crypto helpers from ~/db.server, ~/auth.server, etc., so a stray browser-side import of a server-only utility fails fast at build time rather than leaking secrets.
Sharing Data Across Routes — useRouteLoaderData
When a child route needs data already loaded by an ancestor, don't refetch — read the parent's loader data directly.
// app/root.tsx
export async function loader() {
return json({ user: await getCurrentUser() });
}
// app/routes/dashboard.tsx
import { useRouteLoaderData } from "@remix-run/react";
import type { loader as rootLoader } from "~/root";
export default function Dashboard() {
const data = useRouteLoaderData<typeof rootLoader>("root");
return <h1>Welcome, {data?.user.name}</h1>;
}The string id matches the route module's id ("root" for app/root.tsx; file-based ids for nested routes). Pair with typeof rootLoader to keep SerializeFrom-aware typing.
Headers from the Leaf Loader Win
Only the deepest matching headers export is used by default; parent caching policies are ignored unless you merge them yourself.
// app/routes/products.$id.tsx
import type { HeadersFunction } from "@remix-run/node";
export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? parentHeaders.get("Cache-Control") ?? "no-store",
});Response Caching
For per-route caching, set Cache-Control via the init argument to json():
return json(
{ product },
{
headers: {
"Cache-Control": "private, max-age=60, stale-while-revalidate=300",
},
},
);Choose private for user-specific data; public only when the response is identical for every viewer.
Quick Anti-Pattern Recap
- Fetching in `useEffect` what belongs in a loader — defeats SSR, creates waterfalls, skips automatic revalidation.
- Mutating in a loader — loaders run on GETs and may be prefetched; writes belong in
action. - Returning sensitive fields — everything goes to the browser as JSON; project to a safe DTO.
- Reading `params.foo` without guarding — values are
string | undefined; useinvariantor zod. - Asserting types instead of annotating —
useLoaderData<typeof loader>()is an annotation that drivesSerializeFrom; neveruseLoaderData() as Foo.
Revalidation & Pending State
Remix keeps the UI in sync with the server by re-running loaders automatically after every action. You almost never need to wire up cache invalidation by hand.
Automatic Revalidation
The default behavior, restated from the Remix data-flow discussion:
1. Route loaders provide data to the UI. 2. Forms post data to route actions that update persistent state. 3. Loader data on the page is automatically revalidated after every action.
After a <Form method="post"> submit or a fetcher.submit(), Remix runs the action, then re-runs every loader of every currently matched route on the page — even loaders whose params didn't change. The result: every useLoaderData<typeof loader>() reads fresh data without manual invalidation.
shouldRevalidate — Opt Out
For expensive loaders that don't depend on the action, opt out via the route module's shouldRevalidate export.
import type { ShouldRevalidateFunction } from "@remix-run/react";
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentParams,
currentUrl,
nextParams,
nextUrl,
formMethod,
formAction,
formData,
actionResult,
defaultShouldRevalidate,
}) => {
// Re-validate by default, except for parent search-param changes that
// don't affect this route's data.
if (currentUrl.pathname === nextUrl.pathname && currentParams === nextParams) {
return false;
}
return defaultShouldRevalidate;
};A common safe use is a root loader that returns static env config:
// app/root.tsx
export const loader = async () =>
json({ env: { APP_URL: process.env.APP_URL } });
export const shouldRevalidate: ShouldRevalidateFunction = () => false;Don't Return false Unconditionally Without Thought
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." A permanent opt-out causes stale data after the user's own mutations. Default to defaultShouldRevalidate and only suppress the narrow cases where you can prove the loader's inputs haven't changed.
useRevalidator — Manual Trigger
For pull-style refresh (window focus, polling, server-sent events) use useRevalidator.
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
export function useRevalidateOnFocus() {
const { revalidate, state } = useRevalidator();
useEffect(() => {
function onFocus() {
if (state === "idle") revalidate();
}
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [revalidate, state]);
}useRevalidator() returns { revalidate(): void; state: "idle" | "loading" }. Always gate the call on state === "idle" — multiple concurrent revalidations fire overlapping loader calls.
If you find yourself usinguseRevalidatorfor normal CRUD operations, you're probably skipping<Form>/useSubmit/useFetcherand reinventing the automatic revalidation.
Polling Gotchas
- Synchronized polling from many tabs / users can DDoS your origin. Add jitter.
- Pause polling while the user is scrolling or interacting.
- Concurrent revalidations duplicate DB queries —
state === "idle"is a real guard.
useNavigation — Pending UI
useNavigation() returns the in-flight navigation/submission state for the whole app (not scoped to one form). It is the v2 replacement for v1's useTransition.
import { useNavigation, Form } from "@remix-run/react";
export default function SaveButton() {
const nav = useNavigation();
// POST: idle → submitting → loading → idle.
// GET: idle → loading → idle.
const busy = nav.state !== "idle" && nav.formMethod === "POST";
return (
<Form method="post">
<button type="submit" disabled={busy}>
{busy ? "Saving…" : "Save"}
</button>
</Form>
);
}The shape: { state, location, formData, formAction, formMethod }. The submission object from v1 is flattened directly onto the navigation; there is no nav.submission.formMethod in v2.
v1 useTransition → v2 useNavigation
If you see useTransition from @remix-run/react, it's a v1 holdover. In @remix-run/react@2.x it still exists as a deprecated forwarder to useNavigation, so code compiles — schedule for replacement before the next major. Replacements:
| v1 | v2 |
|---|---|
useTransition() | useNavigation() |
transition.submission.formMethod | nav.formMethod |
transition.submission.formData | nav.formData |
transition.state === "submitting" | nav.state === "submitting" |
transition.type === "actionSubmission" | nav.state === "submitting" && nav.formMethod !== "GET" |
(Note: React 18 / React 19 also export a useTransition from react itself — that hook is unrelated and has a different API. The collision is unfortunate; the Remix one is gone, the React one stays.)
formMethod Is UPPERCASE in v2
nav.formMethod === "POST" // correct
nav.formMethod === "post" // silently never matches — v1 lowercase holdoveruseNavigation, useFetcher, and shouldRevalidate all return UPPERCASE methods in v2. Greping a v2 codebase for === "post" (lowercase) is a fast way to surface broken pending-state checks.
fetcher.type Is Gone
v1 code like if (fetcher.type === "actionSubmission") no longer compiles. Branch instead on fetcher.state plus presence of fetcher.formData:
const submitting = fetcher.state === "submitting" && fetcher.formData != null;GET Submissions Skip "submitting"
A <Form method="get"> goes idle → loading → idle — never enters "submitting". Spinners gated only on state === "submitting" will silently miss GET form filtering.
Decision Sketch
| Goal | API |
|---|---|
| Refetch loaders after a write | Nothing — Remix does it automatically |
| Opt out of revalidation for an expensive parent loader | shouldRevalidate returning false |
| Refetch on window focus / interval / SSE message | useRevalidator (gate on state === "idle") |
| Show pending state during navigation / submission | useNavigation |
| Show pending state for an inline (non-nav) mutation | fetcher.state + fetcher.formData |
Optimistic UI from fetcher.formData
A fetcher exposes the FormData it's currently submitting. Read it to predict the next state before the server has responded.
import { useFetcher } from "@remix-run/react";
export function StarButton({ project }: { project: Project }) {
const fetcher = useFetcher<typeof action>();
const starred = fetcher.formData
? fetcher.formData.get("starred") === "1"
: project.starred;
return (
<fetcher.Form method="post" action={`/projects/${project.id}/star`}>
<input type="hidden" name="starred" value={starred ? "0" : "1"} />
<button aria-pressed={starred}>{starred ? "★" : "☆"}</button>
</fetcher.Form>
);
}When the fetcher resolves, automatic revalidation refreshes loader data and the component reconciles to the server's truth — no manual rollback code.
v2_normalizeFormMethod Future Flag
In late Remix v1 (1.16+), the v2_normalizeFormMethod future flag opted in early to the v2 UPPERCASE-method behavior described above. In a true v2 codebase the flag is a no-op (the behavior is the default), but if you're working on a v1→v2 migration branch, enabling that flag before the version bump lets you fix formMethod comparisons incrementally.