Remix v2 数据加载与变更。用于编写 loader、action、延迟数据、重新验证逻辑或 pending状态。触发于 loader、action、useL...
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}`);
}
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 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:
{ 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().
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>
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 useEffect#Is the data needed for correct first render of this route (SSR, prefetch, automatic revalidation after actions)?
loader + useLoaderData<typeof loader>(). Stop.Is the fetch driven by post-mount user interaction, timer, or subscription (not route entry)?
useEffect / event handlers. Stop.json() vs raw Response vs defer()#Do any returned fields need to stream (slow query, expensive aggregation) while the page renders fast?
defer({ critical: await…, slow: promiseWithoutAwait }) + <Suspense><Await>…</Await></Suspense>. Stop.Do you need a custom status code, custom headers, or explicit TypedResponse<T> typing?
json(data, init) (or redirect(url, init) for 3xx). Stop.Do you need a non-JSON body (binary, plain text, streamed file)?
new Response(body, init). Stop.json(data) — it's the documented v2 contract for object payloads.<Form> / route action vs useFetcher#Should the URL or history stack change (bookmark / share / back returns to prior screen)?
<Form method="post"> posting to a route action. Stop.Mutation stays on the same route (inline edit, list-row toggle, popover, optimistic UI)?
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).Concernv1v2Navigation hookuseTransition()``useNavigation()Submission shapetransition.submission.formMethodflat nav.formMethod / nav.formData``formMethod casing"post"``"POST" (UPPERCASE)Fetcher type fieldfetcher.type === "actionSubmission"branch on fetcher.state + fetcher.formDataLoader args typeLoaderArgs / ActionArgs``LoaderFunctionArgs / ActionFunctionArgsReturning datajson(data) requiredjson(data) still the documented contract