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" />. useActionDatais scoped to the current route. It cannot access action results from parent or child routes; to share, lift the action or use auseFetcherwithkey.- 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
- 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.
- Pass →
- 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.
- Pass →
json() vs raw Response vs defer()
- 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.
- Pass →
- 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.
- Pass →
- 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.
- Pass → Build a raw
<Form> / route action vs useFetcher
- 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.
- Pass →
- Mutation stays on the same route (inline edit, list-row toggle, popover, optimistic UI)?
- Pass →
useFetcher()/fetcher.Form/fetcher.submit(). Stop.
- Pass →
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 |