Install
openclaw skills install remix-v2-data-flowRemix 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).
openclaw skills install remix-v2-data-flowLoader + 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}`);
}
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.
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.
json() Is Optional in v2v2 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:
{ status: 400 } for validation errors).Set-Cookie).TypedResponse<T> for clean useLoaderData<typeof loader>() inference.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().
<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>
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.
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.
<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 export. Parent caching policies are ignored unless you explicitly merge parentHeaders.shouldRevalidate to opt out of expensive parent loaders.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.
Answer in order. Pass means the condition is true; pick the API on the same line and stop.
loader vs useEffectloader + useLoaderData<typeof loader>(). Stop.useEffect / event handlers. Stop.json() vs raw Response vs defer()defer({ critical: await…, slow: promiseWithoutAwait }) + <Suspense><Await>…</Await></Suspense>. Stop.TypedResponse<T> typing?
json(data, init) (or redirect(url, init) for 3xx). Stop.new Response(body, init). Stop.json(data) — it's the documented v2 contract for object payloads.<Form> / route action vs useFetcher<Form method="post"> posting to a route action. Stop.useFetcher() / fetcher.Form / fetcher.submit(). Stop.loader signature, typed useLoaderData, json() vs raw Response, redirect(), throwing, sensitive-data filtering, params handling.action signature, FormData parsing, useActionData<typeof action>(), zod/valibot validation, redirect-after-success.defer() + <Await> + <Suspense>, when streaming helps TTFB, error handling.shouldRevalidate, useRevalidator, useNavigation (plus v1 useTransition rename).| 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 |