首页
/ React Router 的 useLoaderData / useActionData 类型推断 ADR:从盲目类型断言到基于泛式的端到端类型安全

React Router 的 useLoaderData / useActionData 类型推断 ADR:从盲目类型断言到基于泛式的端到端类型安全

2026-09-05 14:44:35作者:谭伦延

本文解析 React Router 架构决策记录 0003 的核心内容:为什么 Remix v1.6.4 时代的 useLoaderData<MyData>() 泛式本质上是一次"盲目断言"、Date 序列化陷阱如何暴露该设计的缺陷,以及"显式提供隐式输入类型、再推断返回类型"这一决策如何解决 loader/action 与组件之间跨网络的类型对齐问题。读完本文,你将理解该 ADR 的完整论证链、六条解决标准,以及这套设计在 React Router 7 源码中(SerializeFromdata() 等)的最终落地形态,还能看清它后来如何被 ADR 0012 类型推断 的 typegen 方案取代。

一、ADR 背景:v1.6.4 的"手工对类型"时代

该 ADR(日期 2022-07-11)的目标是:以优秀的开发者体验(DX)实现 useLoaderDatauseActionData 的端到端类型安全。在 Remix v1.6.4 中,两个 hook 的泛式都要求用户手动指定一个数据类型:

type MyLoaderData = {
  /* ... */
};
type MyActionData = {
  /* ... */
};

export default function Route() {
  const loaderData = useLoaderData<MyLoaderData>();
  const actionData = useActionData<MyActionData>();
  return <div>{/* ... */}</div>;
}

为了获得"端到端"的类型安全,用户还必须在 loader / action 中保证 json 泛式使用同一个类型:

export const loader: LoaderFunction = () => {
  return json<MyLoaderData>({
    /* ... */
  });
};

export const action: ActionFunction = () => {
  return json<MyActionData>({
    /* ... */
  });
};

也就是说:一份数据形状,要写两遍类型(甚至三遍),且没有任何机制保证两边一致。

二、深挖 v1.6.4 源码:泛式只是把 any 强转成 T

ADR 追溯了 v1.6.4 中 @remix-run/react 的源码,发现 useLoaderData 返回的实际上是一个 any 类型,被隐式强转成泛式传入的任何类型:

export function useLoaderData<T = AppData>(): T {
  return useRemixRouteContext().data;
}

interface RemixRouteContextType {
  data: AppData; // AppData = any
  id: string;
}

export type AppData = any;

化简之后就是:

let data: any;

// 某处,`loader` 被调用并把某个值赋给 `data`

function useLoaderData<T>(): T {
  return data; // <-- TypeScript 把这个 `any` 强转为 `T`
}

关键结论:useLoaderData 的返回类型既不基于 data 是怎么被设置的(即 loader 的返回值),也不做任何数据校验,而是盲目地把 data 强转为用户传入的泛式 T

双重代价:冗余代码 + 序列化陷阱

ADR 指出了当前方案的两个问题:

  1. DX 差、代码冗余:用户必须手写数据类型的重复声明。数据形状一旦变化,既要改声明的 type / interface,又要改 json 的实参——而这些类型本可以从 json 的实参中推断出来。
  2. Date 序列化陷阱(footgun):当前方案鼓励用户给 jsonuseLoaderData 传同一个类型,但这恰恰是个坑——json 可以接受 Date 这类可 JSON 序列化的类型,而 useLoaderData 拿到的却是序列化后的类型:
type MyLoaderData = {
  birthday: Date;
};

export const loader: LoaderFunction = () => {
  return json<MyLoaderData>({ birthday: new Date("February 15, 1992") });
};

export default function Route() {
  const { birthday } = useLoaderData<MyLoaderData>();
  // ^ useLoaderData 骗过 TypeScript 认为这是 Date,实际上运行时它是一个 string!
}

useActionData 同理。数据经过网络传输必然是 JSON 序列化后的产物,而类型系统对此视而不见——这是一整类"编译通过、运行出错"的隐患。

三、解决方案标准(Solution Criteria)

ADR 给出了六条硬约束,任何候选方案都必须满足:

  • useLoaderData / useActionData 的返回类型应当loader / action 推断出来,而不是盲目类型断言;
  • loader / action 自身的返回类型应当是可推断的,这就要求 json 的返回类型能从其实参推断;
  • 不允许模块副作用(因此像 makeLoader 这样的高阶函数方案被直接排除);
  • json 应当允许 JSON.stringify 允许的一切;
  • json 应当只允许 JSON.stringify 允许的东西;
  • useLoaderData 不应返回 JSON.parse 无法产生的任何东西。

第 4、5、6 条共同刻画了核心不变量:loader 端的可序列化输入约束 与 组件端的"反序列化输出"类型约束必须严格对应,从而消灭 Date 陷阱这一类错误。

四、关键洞察:loaderuseLoaderData 的"隐式输入"

对"用 TypeScript 泛式推断 hook 返回类型"曾有过犹豫(ADR 引用了社区讨论),因为 TypeScript 泛式天生适合描述/推断输入,而不是用来盲目断言输出

突破点在于认识到:loaderaction 其实是 useLoaderData / useActionData隐式输入。换句话说,如果保证 loaderuseLoaderData 运行在同一进程中(不跨网络),我们完全可以写成 useLoaderData(loader),把 loader 变成显式输入:

// 概念上 `loader` 是 `useLoaderData` 的输入
function useLoaderData<Loader extends LoaderFunction>(loader: Loader) {
  /*...*/
}

现实中 loader 在浏览器运行时并不存在(它跑在服务端),useLoaderData 需要在编译期获知 loader 的类型。而 loaderuseLoaderData 由框架统一管理、跨越网络协作,"拿到的数据与自己的 loader 不对应"是极其罕见的边界情况——因此用一个泛式参数把 loader 的类型"显式注入"给 hook 是安全且合理的。ADR 还类比为 Prisma:尽管存在"编译期之后、运行期之前数据库 schema 被修改"这类罕见边界情况,Prisma 依然从运行期可用的 schema 推断类型。

五、决策:显式提供隐式输入的类型,再推断返回值

最终决策:useLoaderData 显式提供其隐式输入 loader 的类型,然后由 hook 推断自己的返回类型action / useActionData 同理:

export const loader = async (args: LoaderArgs) => {
  // ...
  return json(/*...*/);
};

export default function Route() {
  const data = useLoaderData<typeof loader>();
  // ...
}

注意这里不再是手写 MyLoaderData 这类独立类型,而是 typeof loader——类型直接锚定在 loader 的真实返回类型上,冗余声明被彻底消除。同时,useLoaderData 推断出的返回类型只包含可序列化的(JSON)类型,从类型层面兑现了"只返回 JSON.parse 能产生的东西"这条标准。

省略泛式时返回 unknown

如果 useLoaderData / useActionData 省略泛式就返回 any,会掩盖潜在的类型错误。ADR 决定改为返回 unknown

type MyLoaderData = {
  /*...*/
};

export default function Route() {
  const data = useLoaderData();
  // ^? unknown
}

ADR 同时注明:由于这是破坏性变更,把缺省返回类型改为 unknown 被排期到 v2。

弃用"非推断"的泛式写法

直接传一个手写的非推断类型给 useLoaderData,本质是在隐藏一次不安全的类型断言。ADR 决定弃用该写法,引导用户改用显式类型断言——断言清楚地表达了"我在此处做了假设":

export default function Route() {
  const dataGeneric = useLoaderData<MyLoaderData>(); // <-- 将被弃用
  const dataCast = useLoaderData() as MyLoaderData; // <- 改用这种写法
}

六、决策的后果与硬性约束

ADR 明确列出了该决策带来的行为变化:

  • 用户仍可继续提供非推断类型,方式是对 useLoaderData / useActionData 的返回值做类型断言;
  • 用户通过在泛式中写 typeof loader / typeof action选择性加入类型推断;
  • loader / action 的返回类型成为 useLoaderData / useActionData 推断类型的唯一事实来源(source of truth);
  • 用户不再需要为跨网络对齐类型而写冗余代码;
  • useLoaderData / useActionData 的返回类型将与 json 调用中数据序列化后的类型严格对应,消灭一整类错误;
  • 选择类型推断时,不应再标注 LoaderFunction / ActionFunction——它们会覆盖推断出的更窄的返回类型[^1]。

[^1]: 原 ADR 脚注引用了当时 TypeScript 提案中的 satisfies 运算符:它能约束函数类型的同时保留更窄的推断返回类型,从而让 LoaderFunction / ActionFunction 与类型推断共存。

🚨 最关键的硬性约束:选择类型推断的用户必须从 json 返回 TypedResponse,绝不能返回裸对象

const loader = () => {
  // NO
  return { hello: "world" };

  // YES
  return json({ hello: "world" });
};

只有经过 json(后在 React Router 7 中更名/演进为 data)包装的数据,其返回类型才能被正确推断并施加序列化约束;裸返回的对象类型无法参与这一推断链条。

七、当前仓库中的落地印证:SerializeFromdata()

ADR 描述的是历史设计,但 React Router 7 的源码完整保留并工程化了这套思想。可以对照以下实现逐条验证:

1. hook 的当前签名——"泛式输入 + 序列化后输出"。packages/react-router/lib/hooks.tsx 中:

export function useLoaderData<T = any>(): SerializeFrom<T> {
  let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
  let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
  return state.loaderData[routeId] as SerializeFrom<T>;
}

export function useActionData<T = any>(): SerializeFrom<T> | undefined {
  let state = useDataRouterState(DataRouterStateHook.UseActionData);
  let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
  return (state.actionData ? state.actionData[routeId] : undefined) as
    | SerializeFrom<T>
    | undefined;
}

返回值不再是裸的 T,而是 SerializeFrom<T>——这正是 ADR "推断出的返回类型只包含可序列化 JSON 类型" 的类型学实现。官方文档 useLoaderDatauseActionData 中的示例仍然使用 useLoaderData<typeof loader>() 这一 ADR 确立的用法。

2. SerializeFrom 的完整定义。packages/react-router/lib/types/route-data.ts 中,该类型先判断函数参数的形态,再决定走"服务端数据"还是"客户端数据"路径:

export type SerializeFrom<T> = T extends (...args: infer Args) => unknown
  ? Args extends [
      | ClientLoaderFunctionArgs
      | ClientActionFunctionArgs
      | ClientDataFunctionArgs<unknown>,
    ]
    ? ClientDataFrom<T>   // 客户端函数:数据不过网络,原样保留
    : ServerDataFrom<T>  // 服务端函数:施加序列化转换
  : T;
  • ServerDataFrom 会对其返回值套用 Serialize<T> 递归映射(同文件的 L16-L46):先识别 unstable_SerializesTo 品牌类型,已可序列化的类型原样保留,函数一律映射为 undefined,并递归处理 PromiseMap/Set、数组、元组与对象——这比 ADR 原始设想的"纯 JSON 字符串化"更进一步,与 turbo-stream 传输层支持的容器类型精确对应;
  • ClientDataFrom 则跳过序列化映射,因为 clientLoader / clientAction 的数据不跨网络;
  • 同文件还定义了 GetLoaderData / GetActionData(L174-L208),处理 loader + clientLoader + clientLoader.hydrate + HydrateFallback 组合下的数据形态——这恰是后续 ADR 0012 中那张"组合表格"的类型学基础。

3. 文件内的类型级测试。 route-data.ts 末尾内置了一组 Expect<Equal<...>> 类型测试,直接验证了 ADR 关心的行为,例如:

Expect<
  Equal<
    ServerDataFrom<() => { a: string; b: Date; c: () => boolean; d: unstable_SerializesTo<number> }>,
    { a: string; b: Date; c: undefined; d: number }
  >
>

c(函数)被映射为 undefinedd(带序列化品牌)被映射为 number——正是"loader 端只允许可序列化输入、组件端只得到可反序列化输出"这一不变量的可执行证明。

4. json 约束的运行时对应物。 ADR 中"必须走 json"的约束,在当前仓库对应 data() 辅助函数及其 Serializable 入参约束,定义于 packages/react-router/lib/server-runtime/single-fetch.tsSerializable 是一个递归类型(string | number | boolean | bigint | Date | URL | RegExp | Error | Map | Set | Promise | 数组 | 对象 的递归联合),data(value: Serializable, init?) 的签名把它变成了编译期检查。这同时满足了 ADR 解决标准中"json 允许且只允许 JSON.stringify 允许的东西"两条——函数、Symbol 等不可序列化值在类型层面即被拒绝。

八、结局:被 ADR 0012 取代——从"typeof loader"到 typegen

ADR 头部明确标注了状态:Superseded by #0012(即 decisions/0012-type-inference.md,日期 2024-09-20)。0012 指出 typeof loader 方案虽有实质改进(区别于 useParams<"id"> 那种纯断言泛式),但仍是样板代码且随应用规模放大容易出错,尤其 clientLoader + hydrate + HydrateFallback 的组合下"泛式的正确写法"极其繁琐。

最终方案是放弃用户手写泛式,改为代码生成(typegen):对 routes.ts 返回的每一条路由,把 route 模块的类型生成到 gitignored 的 .react-router/types 目录下(路径镜像,如 app/routes/product.tsx 对应 +types.product.ts),借助 tsconfig.jsonrootDirs 选项让用户像从兄弟文件一样 import { LoaderArgs, DefaultProps } from "./+types.product",并把 paramsloaderDataactionData 作为 props 直接注入 default 组件——useLoaderData 等 hook 因向后兼容保留,但目标是逐步弃用。0012 还系统否决了 defineRoutedefineLoader 系列、Svelte Kit 式"零成本类型安全"(语言服务插件注入)和 TypeScript 插件等替代路线,理由包括 tree-shaking/HMR 兼容性与工具链(typescript-eslinttsc)的互操作。

从 0003 到 0012 的演进脉络值得注意:0003 确立了"以 loader/action 返回类型为类型事实来源 + 序列化感知"这两个核心原则,0012 只是把"由用户手写 typeof loader 泛式"替换为"由 typegen 自动注入",而 0003 中 SerializeFrom 所依赖的序列化映射逻辑则原样保留在今天的 route-data.ts 中。

九、实践要点小结

  • 在本仓库对应的 React Router 7 代码中,推荐写法仍是 useLoaderData<typeof loader>()(见 hooks.tsx 的官方示例注释);若框架模式已启用 typegen,则优先使用生成的 Route.LoaderArgs / props 方案;
  • loader/action 必须返回 data(...)(v7 中 json 的继任者)包装的 TypedResponse,裸对象返回会绕过序列化感知类型;
  • 需要给特殊自定义序列化类型声明"序列化后形态"时,使用 unstable_SerializesTo 品牌类型,而不是手写断言;
  • 理解 ADR 的论证结构(现状剖析 → 解决标准 → 关键洞察 → 决策 → 后果与约束)是阅读本仓库 decisions/ 目录下其他 ADR 的通用模板,ADR 模板 可作参考。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384