在 Server Actions、createServerFn 和 tRPC 中使用同一份邀请表单
有三种方式可构建邀请表单:Next.js Server Actions、TanStack createServerFn 以及 tRPC,本文将从类型、打包体积、无 JS 运行模式以及 Zod 错误处理等方面对它们进行比较。
来源: One Form, Three Backends: Server Action, createServerFn, tRPC.
三种后端,一个邮箱字段
以三种方式连接同一个邀请字段,然后比较真正重要的因素:数据传输中的类型、套餐的收费情况,以及处理程序抛出异常或JavaScript被禁用时的反应。
用户界面极为简单。只有一个email输入框,点击提交即可。成功时按钮下方会显示一条提示,失败时也会显示一条提示。这就是整个产品的界面。
处理程序包括:Next Server Action、TanStack的createServerFn以及tRPC突变函数。它们共享相同的Zod模式和数据副本。应用结构仍是那四个路由,另外还包含一个Start树结构,这样createServerFn就不会显得多余。那些宣称某套技术是“2026年标准”的宣言对表单并无帮助。表单只关心错误是否以可显示的字符串形式出现、类型在数据传输过程中是否保持不变,以及在没有客户端JavaScript的情况下页面是否仍能正常运行。
每套技术实际能提供的功能
在 Next 中,用 "use server" 标记一个函数,并将表单的 action 属性指向该函数——可选地通过 useActionState 来处理待处理和结果状态。
在 TanStack Start 中,createServerFn 会生成一个带有验证功能的类型化可调用函数。从原生表单提交中调用它需要自行编写适配器,因为它默认不会作为渐进式表单操作出现。
在 tRPC 中,你需要定义一个变异处理程序,并从客户端调用它。该设计并不支持无需 JavaScript 即可运行。
那些已发布的契约为这项实验奠定了框架。
随处复用的架构
export const inviteSchema = z.object({
email: z.string().email(),
});
export type InviteInput = z.infer<typeof inviteSchema>;
一个架构,三种处理方式。如果某个框架强制使用相同规则的不同实现形式,那将会增加成本并体现在性能评估中。
服务器操作路径
"use server";
import { inviteSchema } from "./schema";
export async function invite(prev: { error?: string } | null, formData: FormData) {
const parsed = inviteSchema.safeParse({
email: formData.get("email"),
});
if (!parsed.success) return { error: parsed.error.issues[0].message };
await db.invites.create(parsed.data.email);
return { error: undefined, ok: true };
}
"use client";
import { useActionState } from "react";
import { invite } from "./actions";
export function InviteForm() {
const [state, action, pending] = useActionState(invite, null);
return (
<form action={action}>
<input name="email" type="email" required />
<button disabled={pending}>Invite</button>
{state?.error ? <p>{state.error}</p> : null}
</form>
);
}
即使禁用了 JavaScript,表单仍然可以提交。这就是 Action contract,在本实验中得到了验证。
createServerFn 路径
export const inviteFn = createServerFn({ method: "POST" })
.inputValidator(inviteSchema)
.handler(async ({ data }) => {
await db.invites.create(data.email);
return { ok: true as const };
});
在启动时,表单会从 onSubmit 调用 inviteFn({ data: { email } })。如果没有额外的适配器,这条路径就需要 JavaScript。通过创建一个虚假的渐进增强封装层并假装它随框架一同提供,可以欺骗比较结果。
tRPC 路径
invite: publicProcedure.input(inviteSchema).mutation(async ({ input }) => {
await db.invites.create(input.email);
return { ok: true as const };
}),
客户端的用法是 api.invite.useMutation()。input.email 的类型定义非常完善。没有 JavaScript 的话,按钮就毫无用处。
三者的评分情况
类型。 tRPC 和 createServerFn 可以通过 Zod 自动推断输入类型,无需额外的客户端类型定义。服务器端操作接收 FormData 并依赖 safeParse 进行解析。虽然安全,但与带类型的客户端调用并不相同。
打包。 操作模块的体积几乎没有增加。tRPC 已经包含了客户端/提供端所需的功能,这些本就是 API 接口必须具备的。而 createServerFn 则需要使用 Start 的函数式客户端。现有的 tRPC 能够降低数据修改的成本,但为单个邮件新建的 tRPC 并不能做到这一点。
异常处理。 当强制让 db.invites.create 失败时:
- 操作层面:返回的值会被存入
useActionState,并在按钮下方显示。未被捕获的异常会一直传递到 Next 的错误处理机制中,直到被妥善处理。 createServerFn:对应的承诺会被拒绝,可在onSubmit中对其进行处理。
isError;映射方式相同。关闭JS后。该操作有效,另外两种则无效。这是协议差异而非优劣之分。那些无需JS即可正常运行的营销设置页面应使用Actions。已经依赖JS的仪表板则可忽略这一维度。
架构之外的考量
耗费时间的是适配器,而非Zod。
错误格式曾多次变化,最终统一为{ error?: string }。tRPC更倾向于使用error.message,只需进行一次映射即可。切勿让三种不同格式的错误信息出现在按钮下方的段落中。
认证方式存在差异:Actions依赖文档请求的cookie;tRPC使用过程上下文;createServerFn则使用Start的请求。这些方式都可能是正确的,只有其中一种才是“已提交的表单”。
操作成功后的缓存刷新次数也增加了三倍:动作 updateTag("invites")、tRPC 的 utils.invites.list.invalidate(),以及启动加载器失效功能。同一句话,三种表达方式。
何时使用哪种方案
当控件为 Next 框架中的真实表单且仍需避免使用 JS 时,建议使用服务器端动作。若需要内联复制功能,请用 useActionState 封装代码、返回错误信息,并避免在边界之外抛出异常。
若已使用 Start 框架且调用方为类型化的客户端,建议使用 createServerFn。不要仅仅为了某个表单而采用 Start 框架。
当 API 是面向移动端或合作伙伴的产品时,建议使用 tRPC。不要仅仅为了发送一封邮件就安装 tRPC。
除非正在对它们进行性能测试,否则不要在一个应用中同时使用这三种方案。
明确限制
仅在字段为真实命名的输入控件时,“禁用JS处理操作”功能才会生效。仅存在于React中的状态无法通过该测试。
实验中检测的是异常抛出、类型问题以及禁用JS后的提交情况,而非总处理时间。如果在你承诺功能正常后,禁用JS后按钮仍无反应,则说明该页面选择的后端方案不合适。
执行三次提交操作:启用客户端脚本、禁用客户端脚本,以及模拟数据库故障。保留那些在按钮下方仍能显示状态行的路径——这些就是证据。
三种邀请路径,三种错误表现
在设置邀请表单中,同一个无效邮箱导致了三种不同的界面表现:useActionState使操作项被下划线标出,createServerFn抛出异常并被捕获到通用错误处理机制中,而tRPC则将Zod验证错误存储在error.data.zodError字段且未进行映射处理。三者延迟大致相同(约40毫秒)。问题根源在于产品设计的差异。
仅使用 Toast 的 Invalid 包装器会删除字段标识。永久的解决方案是使用一个映射器:
function fieldErrors(err: unknown): Record<string, string> {
if (err && typeof err === "object" && "issues" in err) {
const issues = (err as { issues: { path: (string|number)[]; message: string }[] }).issues;
return Object.fromEntries(issues.map((i) => [String(i.path[0]), i.message]));
}
return { form: "Could not send invite" };
}
每个后端在呈现给用户界面之前都会经过该映射器处理。无效的邮箱地址始终会显示下划线。将操作保留在“Next”设置页面中,createServerFn 放在“Start experiment”页面,worker mobile 已经支持 tRPC。只要多个后端共享相同的架构和映射器,则没有问题;但若错误类型不同则不行。
复现方法
先提交空内容、nope,然后再提交有效的地址。捕获 JSON 数据或操作状态。如果消息的路径不同,那就相当于发布了三种不同的产品。
可行示例:一个架构,三个条目
export const Invite = z.object({
email: z.string().email(),
role: z.enum(["admin", "member"]),
});
export type Invite = z.infer<typeof Invite>;
服务器操作:
"use server";
export async function inviteAction(_prev: unknown, form: FormData) {
const parsed = Invite.safeParse({
email: form.get("email"),
role: form.get("role"),
});
if (!parsed.success) return { errors: fieldErrors(parsed.error) };
await sendInvite(parsed.data);
return { errors: {} };
}
tRPC:
invite: publicProcedure.input(Invite).mutation(({ input }) => sendInvite(input)),
createServerFn:
export const inviteFn = createServerFn({ method: "POST" })
.validator(Invite.parse)
.handler(({ data }) => sendInvite(data));
UI始终读取errors.email。无法生成该键的后端不会被集成到此表单中。
给下一位工程师的指导
模式定义放在一个文件中,映射器也放在一个文件中。只有以该映射器为结尾的传输方式才被允许使用,仅显示提示信息的错误则不被支持。在Action路径上保留invite设置,而在tRPC中保持mobile设置。即便本周的文章赞扬了GraphQL,也无需强行添加第四种传输方式。
实验环节指令
在记录结果之前先记录工具链版本:
node -v
pnpm exec tsc -v
pnpm exec next --version
然后启动应用程序:
pnpm exec next dev
依次访问/、/invoices、/invoices/1、/settings,然后再返回。保留DevTools的日志记录。执行类型检查:
pnpm exec tsc --noEmit --pretty false
echo $?
退出码为0仅表示可以查看运行时状态,而非产品质量的证明。在您的机器上重新运行JS开启/关闭/抛出异常的测试——温度、内存以及Chrome标签页都会影响执行时间,其影响程度远超过框架的次要更新。
请保留一行简短的“修复失败”记录(尝试了X,仍出现Y问题),这样笔记本才能作为证据使用,而非宣传资料。
故障日志与检查清单
tRPC需要的是error.data.zodError.fieldErrors.email[0],而Actions需要的是state.errors.email。最初抛出的是原始的ZodError,需统一为errors.email: string格式;其他任何情况都属于映射器故障。
曾出现过一个额外的fetch('/api/invite')路由,后来通过添加指向inviteAction的注释将其删除。注释比额外的映射器更有效。
在添加传输层之前:
- 导入共享的架构,不要直接复制
errors.emailnope 时,用户看到的字段仍会显示下划线如果不对任何选项打勾,那么这种更改就相当于另一个产品,而非另一种传输方式。
来自实际使用情况的说明
由于存在三种客户端:Next 设置、开始实验功能以及移动端,该邀请设置对应了三个后端。用户需要输入电子邮件地址。若缺少下划线,后续处理人员发现无效数据时就会生成“显示为已通过”的工单。需注意实际发送的邀请与预期发送的邀请之间的差异。映射器代表产品本身,而传输方式则相当于其基础设施。当再次出现使用 GraphQL 的诱惑时,应重新审视这三种错误处理方式并避免采用。
该实验的价值在于对比矩阵,而非胜出者标识。需记录在禁用JS后哪些路径仍能正常运行、哪些会在没有映射器兜底机制的情况下返回基于字段键的错误,以及哪些已与您正在使用的客户端匹配。每当有人提议再增加一个邀请端点时,都应重新查看该矩阵。