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}`);
}
Route modules export loader / action; components read results via useLoaderData and useActionData. 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 — server-only read, runs on SSR and on client navigations via fetch.action: ({ request, params, context }: ActionFunctionArgs) => Response | Promise — server-only handler for non-GET requests (POST/PUT/PATCH/DELETE).json(data, init?: number | ResponseInit): TypedResponse — ergonomic JSON Response wrapper with status/headers.redirect(url, init?: number | ResponseInit): TypedResponse — 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 is a type annotation, not a as-style assertion. The generic feeds SerializeFrom, 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 typing. Reach for json() whenever you need:
{ status: 400 } for validation errors).Set-Cookie).TypedResponse for clean useLoaderData() 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().
+ useAsyncError()When a promise passed through defer() rejects, an 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 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.
.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 ( 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() . Stop.useEffect / event handlers. Stop.json() vs raw Response vs defer()defer({ critical: await…, slow: promiseWithoutAwait }) + … . Stop.TypedResponse 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. / route action vs useFetcher 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() , zod/valibot validation, redirect-after-success.defer() + + , 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 |
共 1 个版本