tRPC v10 useMutation 全解:用 `@trpc/react-query` 写出端到端类型安全的 mutation
tRPC v10 的 @trpc/react-query 包把服务端 mutation(t.procedure.input(...).mutation(...))暴露成客户端 React Hook trpc.xxx.useMutation()。本篇指南以官方 v10 文档 useMutation.md 为主线,结合仓库中 packages/react-query 的源码实现,讲透「如何定义 mutation → 如何在组件里调用 → 底层是如何被包装为 TanStack Query mutation 的」,让你在业务里写出无 any、天然同步类型、可维护的写操作代码。
核心认知:useMutation 是 TanStack Query mutations 的一层薄封装
v10 文档在开篇就给出了一个极其重要的定位:
@trpc/react-query提供的 hooks 是对@tanstack/react-query的 thin wrapper(薄封装)。关于 options 与使用模式的深度资料,请参考其 mutations 指南。
这句话意味着两件事:
- 你在 TanStack Query v4 里学过的所有 mutation 心智模型(
mutate、mutateAsync、isLoading、status、onSuccess/onError/onSettled、variables、reset等)在useMutation上全部适用; - tRPC 替你解决的是「类型不落地」的问题——路由路径、输入校验、输出推断都由服务端
AppRouter一路推导到客户端 Hook,编译器全程把关。
tRPC v11 之后把 React Query 集成拆分为独立的 @trpc/tanstack-react-query 包,但 v10 的这一设计是理解整个演进的基础。本文按 v10 文档对应版本的语义讲解,并特别标注 v11 中的术语变化(例如 isLoading 在 v11 文档示例中改称 isPending),方便你迁移时对照。
一、服务端:先定义一个 mutation procedure
客户端 useMutation 的类型全部来自服务端路由。v10 文档给出了一个最典型的 mutation 定义——登录过程:接收 { name },返回一个用户对象。
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
export const t = initTRPC.create();
export const appRouter = t.router({
// Create procedure at path 'login'
// The syntax is identical to creating queries
login: t.procedure
// using zod schema to validate and infer input values
.input(
z.object({
name: z.string(),
}),
)
.mutation((opts) => {
// Here some login stuff would happen
return {
user: {
name: opts.input.name,
role: 'ADMIN',
},
};
}),
});
几个关键点:
- 语法与 query 一致:注释中特意强调 “The syntax is identical to creating queries”。mutation 与 query 的唯一区别是把
.query((opts) => ...)换成.mutation((opts) => ...),声明在t.router下的路径(这里是login)即客户端的调用路径。 opts.input是校验后的强类型:zodschema 既做运行时校验,又通过类型推断让opts.input.name在服务端与客户端同时获得string类型。- 返回类型可推断:返回的
{ user: { name, role } }会被inferTransformedProcedureOutput类工具类型捕获,成为客户端data的类型。
二、客户端:在组件里调用 trpc.login.useMutation()
v10 文档给出了完整的组件示例,它也是绝大多数 tRPC React 应用里写操作的标准形态:
import { trpc } from '../utils/trpc';
export function MyComponent() {
// This can either be a tuple ['login'] or string 'login'
const mutation = trpc.login.useMutation();
const handleLogin = () => {
const name = 'John Doe';
mutation.mutate({ name });
};
return (
<div>
<h1>Login Form</h1>
<button onClick={handleLogin} disabled={mutation.isLoading}>
Login
</button>
{mutation.error && <p>Something went wrong! {mutation.error.message}</p>}
</div>
);
}
把这段代码拆开看:
| 片段 | 作用 | 类型来源 |
|---|---|---|
trpc.login.useMutation() |
返回一个受控的 mutation 对象 | 由 AppRouter 中 login procedure 的输入/输出自动推导 |
mutation.mutate({ name }) |
触发执行;入参必须是 zod 校验过的 { name: string } |
inferProcedureInput |
mutation.isLoading |
mutation 执行期间为 true,用来禁用按钮防重复提交 |
TanStack Query mutation 状态机 |
mutation.error |
请求失败时携带 TRPCClientError,可安全访问 error.message |
TRPCClientErrorLike<AppRouter> |
注意注释里提到的一点:路径既可以是字符串 'login',也可以是元组 ['login']。当你的路由是多层嵌套(例如 post.edit,或 ['post', 'edit'])时,两种写法等价——这一点后面会结合代理源码解释。
关于状态命名的版本差异
v10 文档示例中的按钮禁用条件是 mutation.isLoading(TanStack Query v4 对 mutation 提供的标志位)。在 v11 相关文档的等价示例里,该写法已经演变为 mutation.isPending(TanStack Query v5 的语义),而 v10 时期的 isLoading 在 v5 中含义被重新定义为“首次加载且无数据”。做版本迁移时(可参考 migrate-from-v10-to-v11.mdx),要格外留意这类 React Query 语义变化。
三、底层探秘:trpc.xxx.useMutation 是怎么把代理解析成真 Hook 的
类型安全的魔法来自递归 Proxy。看 decorationProxy.ts 的实现:
return createRecursiveProxy(({ path, args }) => {
const pathCopy = [...path];
// The last arg is for instance `.useMutation` or `.useQuery()`
const lastArg = pathCopy.pop()!;
if (lastArg === 'useMutation') {
return (hooks as any)lastArg;
}
// ...
});
当你写下 trpc.login.useMutation(opts) 时,实际发生的是:
- 代理把调用路径
['login', 'useMutation']捕获下来; - 弹出最后一个段
'useMutation',剩余部分['login']就是 procedure 路径; - 把
pathCopy与opts转发给内部真实的useMutation(path, opts)。
所以“路径是字符串还是元组”其实只是表面形式,最终都被归一化为 readonly string[]。这也解释了为什么 tRPC 能对任意深层嵌套路由做到「无样板、无字符串拼接、全推断」。
真正的 Hook 实现在 createHooksInternal.tsx,它内部调用了 TanStack Query 的 useMutation:
function useMutation(
path: readonly string[],
opts?: UseTRPCMutationOptions<unknown, TError, unknown, unknown>,
): UseTRPCMutationResult<unknown, TError, unknown, unknown> {
const { client, queryClient } = useContext();
const mutationKey = getMutationKeyInternal(path);
const defaultOpts = queryClient.defaultMutationOptions(
queryClient.getMutationDefaults(mutationKey),
);
const hook = __useMutation(
{
...opts,
mutationKey: mutationKey,
mutationFn: (input) => {
return client.mutation(...getClientArgs([path, { input }], opts));
},
onSuccess(...args) {
const originalFn = () =>
opts?.onSuccess?.(...args) ?? defaultOpts?.onSuccess?.(...args);
return mutationSuccessOverride({
originalFn,
queryClient,
meta: opts?.meta ?? defaultOpts?.meta ?? {},
});
},
},
queryClient,
) as UseTRPCMutationResult<unknown, TError, unknown, unknown>;
hook.trpc = useHookResult({
path,
});
return hook;
}
这段源码印证了“薄封装”的定位,同时透露出 tRPC 在薄封装之上加的几层关键逻辑:
mutationKey由路径生成:getMutationKeyInternal(path)保证每个 procedure 有稳定的 mutation key。这让你能基于路径配置 mutation 默认值(queryClient.setMutationDefaults),也让服务端返回后 tRPC 能把该 key 的缓存失效/刷新串起来。mutationFn调用的不是 fetch,而是 tRPC client 的client.mutation:getClientArgs([path, { input }], opts)会组装出「路径 + 已序列化输入 + headers/信号等选项」,走 tRPC 自身的传输层(HTTP 批量、错误规范化都在这一层完成)。onSuccess被包装成mutationSuccessOverride:它把「用户传的opts.onSuccess」「queryClient上的 mutation 默认回调」以及通过config.overrides.useMutation.onSuccess注入的全局逻辑合并成一条执行链。默认实现只是透传调用originalFn(),但这一扩展点正是@trpc/react-query提供给框架作者做“成功后自动失效查询”之类的钩子。- 返回对象额外挂载
hook.trpc:useHookResult({ path })记录当前 mutation 对应的路径,供调试与工具函数(如useUtils())内部使用。
类型契约:MutationLike
如果你在写通用组件、封装表单或自建 HOC,需要“把某个 procedure 的 useMutation 当参数传入”时,仓库提供了对应的鸭子类型抽象 mutationLike.ts:
export type MutationLike<TRoot, TProcedure> = {
useMutation: (
opts?: InferMutationOptions<TRoot, TProcedure>,
) => InferMutationResult<TRoot, TProcedure>;
};
配套的 InferMutationLikeInput / InferMutationLikeData 可以反推出任意 “MutationLike” 的输入与输出类型。这个 Polymorphism 设计(mutation/query/router 分别对应 queryLike.ts、routerLike.ts)是 v10 后期为支持“将整棵 router 对象整体传给子组件”而引入的,让类型系统在不丢失推断的前提下接受“形状匹配”的对象。
四、在业务中用好 mutation 的完整能力
虽然 v10 的 useMutation 文档只给了最小示例,但既然它“就是 TanStack Query mutation”,你就可以放心组合出以下生产级用法(类型均由 tRPC 保证):
1. mutate 与 mutateAsync
mutate(input):fire-and-forget 风格,状态变化通过返回的 mutation 对象响应式呈现,组件里最常见;mutateAsync(input):返回 Promise,适合在事件处理器里做“先写入、再跳转/刷新”等串行逻辑,配合try/catch捕获TRPCClientError。
2. 回调:onSuccess / onError / onSettled
const mutation = trpc.post.create.useMutation({
onSuccess: () => {
// 例如:toast 提示、表单重置
},
onError: (error) => {
// error 是 TRPCClientError,可读 error.message / error.data.code
},
onSettled: () => {
// 无论成败都会执行,适合关闭 loading 遮罩
},
});
由于回调的 error 参数被推断为 TRPCClientErrorLike<AppRouter>,onError 里直接访问 error.data.code(如 UNAUTHORIZED、CONFLICT)就是类型安全的,无需手动断言。
3. mutation 成功后失效并刷新相关查询
写操作之后最常见的诉求是“让相关列表重新拉取”。搭配 useUtils.mdx:
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
});
4. mutation 状态对象概览
一次 useMutation() 返回的对象包含(均由 TanStack Query v4 提供、tRPC 保持透传):
data:成功后服务端返回的强类型数据;error:失败时的TRPCClientError(无失败为null);variables:最近一次mutate传入的输入;status:'idle' | 'loading' | 'success' | 'error';isIdle / isLoading / isSuccess / isError:与status对应的布尔快捷量(v10 语义);mutate / mutateAsync:触发执行;reset():把 mutation 状态清回idle,常用于关闭错误弹窗后重置 UI。
五、类型推断:input 与 output 从哪来
在 mutationLike.ts 中可以看到两条核心推断工具:
inferProcedureInput:把 zod/任意 validator 的 schema 抽成入参类型,即mutate(input)的入参类型;inferTransformedProcedureOutput:获取经过 data transformer(如superjson)处理后的输出类型,即data的类型。
也就是说,服务端 login procedure 一旦改 schema(比如 name 变成 name: z.string().min(1),或返回值多一个字段),客户端 mutation.mutate({...}) 与 data.user 的用法会立刻在编译期报错——这正是 @trpc/react-query “Move Fast and Break Nothing” 的核心体验。关于 createTRPCReact 泛型如何从 setup.mdx 一路注入,以及手动从 Router 提取类型的技巧,可进一步阅读 infer-types.md。
总结
useMutation 的设计哲学可以概括为一句话:mutation 的所有运行时能力交给 TanStack Query,所有端到端类型安全交给 tRPC 的 Proxy 与类型推断,两者通过 mutationKey = procedure path 这一桥梁结合。
- 服务端用
.mutation()+.input(zod)定义写操作; - 客户端用
trpc.<path>.useMutation()获得自带类型、状态机、错误对象与回调的完整 mutation; - 底层在 createHooksInternal.tsx 中把路径映射为
mutationKey,把执行体换成client.mutation,把onSuccess汇入可扩展的 override 链; - 需要抽象/复用 mutation 形状时,使用
MutationLike家族类型保持类型推断不丢失。
想深入对比 query 形态,推荐继续阅读同目录的 useQuery.md;想了解如何在 mutation 后做服务端数据再取回、序列化传输等细节,可分别参考 useUtils.mdx 与 data-transformers.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00