Remix v2 data loading and mutations. Use when writing loaders, actions, deferred data, revalidation logic, or pending state. Triggers on loader, action, useL...
---
name: remix-v2-data-flow
description: Remix v2 data loading and mutations. Use when writing loaders, actions, deferred data, revalidation logic, or pending state. Triggers on loader, action, useLoaderData, useActionData, json(), defer(), <Await>, shouldRevalidate, useRevalidator, useNavigation, useTransition (v1 holdover).
---
# Remix v2 Data Flow
## Quick Reference
**Loader + typed read**:
```tsx
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)**:
```tsx
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 JSON `Response` wrapper 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 clean `useLoaderData<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()`.
```tsx
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 `useFetcher` with `key`.
- **Headers from the leaf loader win.** Remix only uses the deepest matching `headers` export. Parent caching policies are ignored unless you explicitly merge `parentHeaders`.
- **Default revalidation revalidates ALL routes on the page after an action** — even those whose params didn't change. Use `shouldRevalidate` to 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)` (or `redirect(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 route `action`. **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](references/loaders.md) for `loader` signature, typed `useLoaderData`, `json()` vs raw `Response`, `redirect()`, throwing, sensitive-data filtering, params handling.
- **Actions**: See [references/actions.md](references/actions.md) for `action` signature, FormData parsing, `useActionData<typeof action>()`, zod/valibot validation, redirect-after-success.
- **Defer & Await**: See [references/defer-await.md](references/defer-await.md) for `defer()` + `<Await>` + `<Suspense>`, when streaming helps TTFB, error handling.
- **Revalidation & Pending State**: See [references/revalidation.md](references/revalidation.md) for automatic revalidation, `shouldRevalidate`, `useRevalidator`, `useNavigation` (plus v1 `useTransition` rename).
## 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 |
don't have the plugin yet? install it then click "run inline in claude" again.
Remix v2 ships data to the browser through loaders (server-side reads) and actions (server-side mutations). Use this skill when writing route module exports (loader, action), reading that data in components (useLoaderData, useActionData), streaming responses with defer(), or managing revalidation and pending UI. Know this when you're building SSR-first routes, need to decide between a loader and a useEffect, want to throw a redirect for auth guards, or are converting from v1's useTransition to v2's useNavigation.
Remix v2 runtime environment
@remix-run/cloudflare, @remix-run/deno).loader and/or action exports.External dependencies
@remix-run/node (or target adapter) for server utilities: json(), redirect(), defer(), type definitions.@remix-run/react for client hooks: useLoaderData(), useActionData(), useFetcher(), useNavigation(), useAsyncError(), <Form>, <Await>, <Suspense>.FormData in actions.shouldRevalidate function if you need fine-grained revalidation control.Context and params available in loaders/actions
request: Request , the HTTP request object (method, headers, URL, body).params: Record<string, string> , route dynamic segments (e.g. params.projectId from /projects/:projectId).context: Record<string, unknown> , app-level context passed at server init (DB instances, config, etc.).Edge cases and operational constraints
shouldRevalidate to skip expensive parents.ErrorBoundary.defer() require <Suspense> + <Await> on the client. Unhandled promise rejections in deferred data tear down the entire page unless caught by an errorElement in the <Await>.loader for server-side reads (SSR + client navigations)Input: Route module context, LoaderFunctionArgs with request, params, context.
Output: A Response object (typically via json(data, init)).
Declare an async function named loader in your route module. Fetch or compute all data needed for correct first render. Call json(data) to wrap your object and set application/json content-type with a 200 status. If you need a non-200 status (e.g. 404) or custom headers, pass an init object as the second argument.
import { json, type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request, params, context }: LoaderFunctionArgs) {
const { projectId } = params;
const project = await db.project.findUnique({ where: { id: projectId } });
if (!project) {
throw json({ message: "Project not found" }, { status: 404 });
}
return json({ project: { id: project.id, name: project.name } });
}
Type annotation: Use type LoaderFunctionArgs from @remix-run/node to type-check your parameters.
useLoaderData<typeof loader>()Input: The exported loader function.
Output: Typed data object in the component.
Inside the route's default export component, call useLoaderData with the loader as a generic parameter. Pass typeof loader (not an assertion, but a type annotation) so TypeScript infers the shape and applies SerializeFrom<typeof loader> (the wire-format transformation: Date becomes string, class methods vanish, undefined fields are stripped).
import { useLoaderData } from "@remix-run/react";
export default function Project() {
const { project } = useLoaderData<typeof loader>();
// typeof project.name is string (JSON serialized)
return <h1>{project.name}</h1>;
}
Serialization reality check: If your loader returns { createdAt: new Date() }, the wire value is a string ISO timestamp. Calling .getFullYear() on it will crash at runtime. The type annotation already says string, so the type-check catches this.
action to handle mutations (POST/PUT/PATCH/DELETE)Input: Route module context, ActionFunctionArgs with request, params, context.
Output: A Response (redirect on success, or json(errors) on validation failure).
Declare an async function named action in the same route module. Check the request.method if the route handles multiple methods. Parse the request body with await request.formData() (for form submissions) or await request.json() (for JSON payloads). Validate the data with Zod or similar. If validation fails, return json({ errors }, { status: 400 }). If success, mutate the database and return redirect(url) to a new page, or return json(data) if the user stays on the same route.
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { NewProjectSchema } from "~/schemas";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
throw json({ message: "Method not allowed" }, { status: 405 });
}
const form = await request.formData();
const parsed = NewProjectSchema.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}`);
}
<Form method="post"> to trigger the action and revalidate loadersInput: JSX component, form fields.
Output: Route action executed, loaders revalidated, UI updated.
In your component, render a <Form> from @remix-run/react with method="post" (or "put", "patch", "delete"). Remix intercepts the submission, runs the action on the server, and then automatically revalidates all matching loaders. The page stays in sync with the server without manual refetch logic.
import { Form, useActionData } from "@remix-run/react";
export default function NewProject() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="name" required />
{actionData?.errors?.name && <p>{actionData.errors.name}</p>}
<button type="submit">Create</button>
</Form>
);
}
useFetcher for mutations that don't change the URL or historyInput: Component context, fetcher callback.
Output: Mutation without navigation, inline pending state.
For inline edits, list toggles, or popover forms that don't warrant a URL change, call useFetcher() from @remix-run/react. Use fetcher.Form, fetcher.submit(), or fetcher.load() to invoke a separate route's action without navigating. The current route stays put, and only the fetcher's state updates.
import { useFetcher } from "@remix-run/react";
export default function ProjectRow({ project }) {
const fetcher = useFetcher();
return (
<div>
<p>{project.name}</p>
<fetcher.Form method="post" action={`/projects/${project.id}/toggle-archived`}>
<button type="submit">
{fetcher.state === "submitting" ? "Saving..." : "Archive"}
</button>
</fetcher.Form>
</div>
);
}
defer() and <Suspense> + <Await>Input: Loader returning both critical and deferred (non-awaited) promises.
Output: HTML streamed to browser, slow queries resolve in the background.
In a loader, separate fast data from slow data. Await the critical fields, but pass slow promises unawaited into defer(). On the client, wrap the slow data in <Suspense> + <Await> to render a fallback while the promise resolves.
// Server: loader
export async function loader() {
const reviews = db.review.findMany(); // Don't await , this is slow.
const product = await db.product.findUnique({ where: { id: 1 } }); // Critical, await.
return defer({ product, reviews });
}
// Client: component
import { Suspense } from "react";
import { Await } from "@remix-run/react";
export default function Product() {
const { product, reviews } = useLoaderData<typeof loader>();
return (
<div>
<h1>{product.name}</h1>
<Suspense fallback={<div>Loading reviews...</div>}>
<Await resolve={reviews}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>
</div>
);
}
errorElement + useAsyncError()Input: <Await> component with rejected promise, error boundary component.
Output: Inline error UI without tearing down the page.
When a deferred promise rejects, an <Await errorElement={...}> catches it and renders the error component locally. Inside that component, call useAsyncError() to access the rejection value. This keeps the page interactive while a single slow data stream fails.
function ReviewsError() {
const error = useAsyncError();
return <p>Failed to load reviews: {String(error)}</p>;
}
<Suspense fallback={<div>Loading reviews...</div>}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>
Response from a loader or action for early exits (auth, 404, redirects)Input: Loader or action function, guard condition.
Output: HTTP response sent to client (redirect, 404, 403, etc.).
To short-circuit a loader or action (e.g. user not authenticated), throw a Response object. Throwing a plain Error will not be caught by useRouteError() / isRouteErrorResponse(). Use throw redirect() for auth guards and throw json(message, { status: 404 }) for missing resources.
export async function loader({ request, context }: LoaderFunctionArgs) {
const user = context.user;
if (!user) {
throw redirect("/login");
}
const data = await db.secrets.findMany();
return json({ data });
}
useNavigation() to display pending UI during navigation and form submissionInput: Component context.
Output: Pending state flags and form data.
Call useNavigation() from @remix-run/react to read the current navigation state (idle, loading, submitting) and inspect formData, formMethod (uppercase: "POST", "GET", etc.), and location. Branch on state to show spinners or disable buttons during submission.
import { useNavigation } from "@remix-run/react";
export default function ProjectForm() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" disabled={isSubmitting} />
<button disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>
</Form>
);
}
Note on GET vs POST state: POST submissions go idle -> submitting -> loading -> idle. GET submissions go idle -> loading -> idle. Use navigation.formData presence to detect GET form submissions, not state === "submitting".
shouldRevalidate to skip expensive loaders after actionsInput: Route module, ShouldRevalidateFunctionArgs.
Output: Boolean controlling whether a specific loader reruns.
By default, all loaders on a page revalidate after an action succeeds. Export a shouldRevalidate function to opt out of revalidation for specific routes (e.g. a heavy sidebar loader that didn't change). Return false to skip the loader; return true to revalidate.
import { type ShouldRevalidateFunctionArgs } from "@remix-run/react";
export function shouldRevalidate({ actionResult, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
// Skip revalidation if the action didn't touch projects
if (actionResult?.type === "comment-created") {
return false;
}
return defaultShouldRevalidate;
}
export async function loader() {
// Expensive query, only revalidate if needed
const projects = await db.project.findMany(); // slow
return json({ projects });
}
useRevalidator() to manually trigger revalidation (e.g. after a timer or WebSocket message)Input: Component context, revalidation trigger.
Output: All loaders on the page are revalidated.
Call useRevalidator() and invoke revalidator.revalidate() to manually trigger loader reruns. Use this after a timer tick, WebSocket message, or external event that invalidates cached data.
import { useRevalidator, useEffect } from "@remix-run/react";
export default function ProjectList() {
const revalidator = useRevalidator();
useEffect(() => {
const interval = setInterval(() => revalidator.revalidate(), 5000);
return () => clearInterval(interval);
}, [revalidator]);
return <div>Projects (auto-refreshing)</div>;
}
loader or a useEffect?Decision 1: Is the data needed for correct first render (SSR, prefetch, after actions)?
loader + useLoaderData<typeof loader>(). Loaders run on the server before the component tree mounts, so the HTML includes the data. This enables SSR, SEO, and fast first paint.Decision 2: Is the fetch driven by post-mount user interaction, a timer, or a subscription (not route entry)?
useEffect or event handlers. The data is not needed until after the page renders, so fetch it in the browser.useEffect; Remix will revalidate loaders automatically, and manual refetches duplicate that work.json(), raw Response, or defer()?Decision 1: Do any returned fields need to stream (slow query, expensive computation) while the rest of the page renders fast?
defer({ critical: await…, slow: promiseWithoutAwait }) + client-side <Suspense><Await>…</Await></Suspense>. Streaming improves TTFB and user perception.Decision 2: Do you need a custom status code, custom headers, or explicit TypedResponse<T> typing?
json(data, init) with init = { status: 400, headers: {...} } (or redirect(url, init) for 3xx). json() is the documented contract for structured data in v2.Decision 3: Do you need a non-JSON body (binary file, plain text, CSV, streamed stream)?
new Response(body, init) with the appropriate Content-Type header.json(data). It sets application/json, infers clean types for useLoaderData, and is the documented v2 contract.<Form> posting to an action, or useFetcher?**