关于 remix-v2-data-flow
面向 Remix v2 开发者的数据流参考技能,涵盖数据加载、变更、重新验证、流式响应和待处理状态。它通过决策指南与代码示例说明 API 选择及版本迁移注意事项。
主要功能
- 01Loader 与 action 模式
- 02类型化数据与响应处理
- 03使用 defer 流式传输及错误边界
- 04自动重新验证指引
- 05v1 到 v2 的待处理状态差异
适用场景
- 01选择 loader 或客户端副作用
- 02实现路由变更与重定向
- 03处理流式数据错误
- 04检查 GET 和 POST 表单的待处理界面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ existential-birds/ beagle --skill remix-v2-data-flow安装后在 Claude Code 中输入/remix-v2-data-flow确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/actions.mddefer-await.mdloaders.mdrevalidation.md详细介绍
Remix v2 数据流
快速参考
Loader + 类型化读取:
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 + 成功后重定向(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}`);
}
规范 API
路由模块导出 loader / action;组件通过 useLoaderData<typeof loader>() 和 useActionData<typeof action>() 读取结果。每次 action 执行后,Remix 都会自动重新验证页面上所有匹配路由的 loader,因此 UI 无需手动使缓存失效,也能与服务器保持一致。
签名:
loader: ({ request, params, context }: LoaderFunctionArgs) => Response | Promise<Response>— 仅在服务器端执行的读取操作,在 SSR 和通过 fetch 发起的客户端导航中运行。action: ({ request, params, context }: ActionFunctionArgs) => Response | Promise<Response>— 仅在服务器端执行的非 GET 请求处理器(POST/PUT/PATCH/DELETE)。json(data, init?: number | ResponseInit): TypedResponse<typeof data>— 便捷的 JSONResponse封装,可设置状态码和标头。redirect(url, init?: number | ResponseInit): TypedResponse<never>— 30x 响应;默认状态码为 302。
导入:在 Node 上使用 @remix-run/node 中的服务器端工具(json、redirect、defer、类型参数);针对其他运行环境,分别替换为 @remix-run/cloudflare 或 @remix-run/deno。Hooks 和组件来自 @remix-run/react。
使用类型注解,而非类型断言
useLoaderData<typeof loader>() 是类型注解,不是 as 风格的类型断言。泛型会传入 SerializeFrom<typeof loader>,以模拟线上的数据转换:Date 会变成 string,Map/Set 会被压平,undefined 字段会被移除,类方法也会消失。如果你对 Date 字段调用 data.createdAt.getFullYear(),这就是运行时错误——类型早已表明它是 string。
v2 中何时可以不使用 json()
v2 并未改变底层约定:loader 和 action 必须返回 Response。json() 是一种便捷封装,会设置 application/json,并允许你指定状态码和标头。在 v2 中,直接返回对象也可行(Remix 会自动将其封装为 json()),但为了明确指定状态码和标头,并获得清晰的 TypedResponse<T> 类型,首选 json()。在以下情况应使用 json():
- 需要非 200 状态码时(例如,验证错误使用
{ status: 400 })。 - 需要自定义标头时(缓存、
Set-Cookie)。 - 需要明确的
TypedResponse<T>,以便useLoaderData<typeof loader>()获得清晰的类型推断。
使用抛出实现短路
从 loader 或 action 中抛出 Response,会立即退出数据函数。可用于身份验证守卫(throw redirect("/login"))和 404 错误(throw new Response("Not Found", { status: 404 }) 或 throw json({ message }, { status: 404 }))。抛出普通 Error 不会被 useRouteError() / isRouteErrorResponse() 归类为路由响应。
流式传输中的拒绝:<Await errorElement> + useAsyncError()
通过 defer() 传递的 promise 被拒绝时,<Await errorElement={...}> 边界会在行内捕获错误;如果没有它,拒绝会向上冒泡至路由的 ErrorBoundary,导致整个页面被卸载,从而失去流式传输的优势。在 errorElement 内,调用 useAsyncError()(来自 @remix-run/react)读取拒绝值——这是流式传输场景下对应 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>
敏感数据
loader 返回的所有内容都会以 JSON 形式发送到浏览器。返回前应转换为安全的 DTO({ id, email, name });切勿返回完整的 Prisma User、密码哈希、API 密钥或内部标记。Loader 仅在服务器端执行,但其返回值会完整发送给客户端。
变更操作应放在 Action 中
每次 GET 导航都会运行 loader,而且预取可能会提前调用它;自动重新验证期间它也会再次运行。任何会变更持久化状态的操作都必须放在 action 中,并通过 <Form method="post"> 或 useFetcher 调用。直接在组件中调用 fetch() 请求 Remix 路由,会绕过重新验证、待处理状态和渐进增强;请改用 useFetcher().submit() / useFetcher().load()。
需要牢记的路由注意事项
- 只会执行最深层匹配的操作。 嵌套在布局下的索引路由会发生冲突,除非使用
<Form action="/things?index" method="post" />指定目标路由。 useActionData的作用域仅限当前路由。 它无法访问父级或子级路由的操作结果;若要共享结果,请提升操作所在路由,或使用带有key的useFetcher。- 叶子路由的标头优先生效。 Remix 只会使用最深层匹配路由导出的
headers。除非显式合并parentHeaders,否则会忽略父级缓存策略。 - 默认情况下,执行操作后会重新验证页面上的所有路由——即使它们的参数没有变化。使用
shouldRevalidate可跳过开销较大的父级加载器。
待处理状态(v1 → v2)
v2 中已移除 useTransition,请改用 useNavigation。在 v2 中,submission 对象会直接平铺到 navigation 上(fetcher 也一样;nav.formData/nav.formMethod 和 fetcher.formData/fetcher.formMethod 都是平铺的)。v2 中的 formMethod 现在是大写("POST",而不是 "post");像 nav.formMethod === "post" 这样的比较会静默地始终不匹配。fetcher.type 也已移除,应根据 fetcher.state 和 fetcher.formData 是否存在来分支处理。
GET 提交的状态流转为 idle → loading → idle。POST 的状态流转为 idle → submitting → loading → idle。仅在 "submitting" 状态下显示的加载指示器会漏掉 GET 表单。GET 提交在 loading 阶段仍会填充 nav.formData,且 nav.formMethod === "GET";因此,筛选表单的待处理 UI 应根据 formData 是否存在来分支处理,而不是检查 state === 'submitting'。对于 useFetcher,GET(<fetcher.Form method='get'> 和 fetcher.submit(..., {method:'get'}))和非 GET 请求都会进入 submitting;只有 fetcher.load() 会跳过 submitting。这与 useNavigation 相反,后者的 GET 请求会跳过 submitting。
判断关卡(决策顺序)
请按顺序回答。通过表示条件为真;选择同一行的 API 并停止。
loader 还是 useEffect
- 此路由的正确首次渲染是否需要这些数据(SSR、预取、操作后的自动重新验证)?
- 通过 →
loader+useLoaderData<typeof loader>()。停止。 - 未通过 → 第 2 步。
- 通过 →
- 获取数据是否由挂载后的用户交互、计时器或订阅驱动(而非进入路由)?
- 通过 →
useEffect/ 事件处理程序。停止。 - 未通过 → 优先使用 loader 和重新验证;不要在 effect 中复制导航逻辑。
- 通过 →
json()、原始 Response 还是 defer()
- 是否需要在页面快速渲染的同时流式传输某些返回字段(慢速查询、开销较大的聚合)?
- 通过 →
defer({ critical: await…, slow: promiseWithoutAwait })+<Suspense><Await>…</Await></Suspense>。停止。 - 未通过 → 第 2 步。
- 通过 →
- 是否需要自定义状态码、自定义标头或显式的
TypedResponse<T>类型?- 通过 →
json(data, init)(对于 3xx,使用redirect(url, init))。停止。 - 未通过 → 第 3 步。
- 通过 →
- 是否需要非 JSON 请求体(二进制、纯文本、流式文件)?
- 通过 → 构造原始的
new Response(body, init)。停止。 - 未通过 → 默认使用
json(data)——这是 v2 对象载荷的文档规定契约。
- 通过 → 构造原始的
<Form> / 路由操作还是 useFetcher
- URL 或历史记录栈是否应发生变化(书签 / 分享 / 返回时回到先前的屏幕)?
- 通过 → 使用
<Form method="post">,提交到路由的action。停止。 - 未通过 → 第 2 步。
- 通过 → 使用
- 变更是否留在同一条路由上(行内编辑、列表行切换、弹出框、乐观 UI)?
- 通过 →
useFetcher()/fetcher.Form/fetcher.submit()。停止。
- 通过 →
附加文档
- 加载器:请参阅 references/loaders.md,了解
loader签名、类型化的useLoaderData、json()与原始Response的区别、redirect()、抛出错误、敏感数据筛选以及参数处理。 - 操作:请参阅 references/actions.md,了解
action签名、FormData 解析、useActionData<typeof action>()、zod/valibot 验证以及成功后重定向。 - 延迟加载与等待:请参阅 references/defer-await.md,了解
defer()+<Await>+<Suspense>、流式传输有助于 TTFB 的场景以及错误处理。 - 重新验证与待处理状态:请参阅 references/revalidation.md,了解自动重新验证、
shouldRevalidate、useRevalidator、useNavigation(以及 v1 中useTransition的重命名)。
v1 → v2 快速对比
| 关注点 | v1 | v2 |
|---|---|---|
| 导航钩子 | useTransition() | useNavigation() |
| 提交数据结构 | transition.submission.formMethod | 扁平的 nav.formMethod / nav.formData |
formMethod 大小写 | "post" | "POST"(大写) |
| Fetcher 类型字段 | fetcher.type === "actionSubmission" | 根据 fetcher.state + fetcher.formData 分支 |
| Loader 参数类型 | LoaderArgs / ActionArgs | LoaderFunctionArgs / ActionFunctionArgs |
| 返回数据 | 必须使用 json(data) | json(data) 仍是文档规定的约定 |