Next.js 中的表单作为契约:Zod、fieldErrors 和客户端与服务器端的统一规则
表单不仅仅是一堆字段和按钮——它是 UI、验证逻辑和数据源之间的契约。如果没有明确定义这个契约,就会引发混乱:UI 认为数据有效,服务器却抛出错误,提示消息随机出现,表单行为不可预测。解决方案?使用 Zod、safeParse 和 flatten 来统一验证模式、错误格式和状态,确保客户端和服务器端规则完全一致。
没有契约表单为何崩溃
许多项目起步时很简单:输入、状态、提交。小表单没问题。但随着代码库增长,会出现三个关键的分歧点:
- 验证规则不一致。客户端检查字符串长度,服务器却额外标记禁词或空格。用户看到绿色对勾,提交却失败。
- 错误格式不匹配。有时是字符串,有时是对象,再有时是数组。UI 得猜响应结构。
- 状态未定义。加载中、禁用、成功、formError——每个组件处理方式不同,导致用户体验支离破碎。
没有单一契约,每个表单都按自己的本地规则行事,无法扩展。
表单作为契约:关键要素
表单契约必须明确回答这些问题:
- 什么数据才算有效?
- 哪些错误针对特定字段(fieldErrors),哪些针对整体操作(formError)?
- 加载状态如何处理?
- 成功时返回什么?
- 服务器能否返回不同格式的响应?
- 客户端和服务器使用相同规则吗?
这些答案要在实现 UI 前敲定。在 Workbench 中,这种方法用 Zod 模式和一致响应形状标准化了所有表单。
一个 Zod 模式,双重用途
与其在客户端和服务器间复制验证逻辑,不如到处用单一 Zod 模式。笔记示例模式:
import { z } from "zod";
export const noteSchema = z.object({
title: z
.string()
.trim()
.min(3, "Enter minimum 3 simvola")
.max(80, "Maximum 80 characters"),
description: z
.string()
.trim()
.max(500, "Maximum 500 characters")
.optional()
.or(z.literal("")),
}).refine(
data => data.title.toLowerCase() !== "test",
{
path: ["title"],
message: "Withlishkom tekhnicheskoe name for zametki",
}
);
export type NoteInput = z.infer<typeof noteSchema>;
这个模式定义了有效值、转换(如 trim)和自定义规则(refine)。它成为验证的唯一真相来源。
使用 safeParse 安全验证
表单中用 safeParse 而非 parse 至关重要。parse 出错时抛异常——对用户体验有害,因为无效输入很常见。safeParse 返回易处理的结果:
export function validateNote(input: unknown) {
return noteSchema.safeParse(input);
}
结果包含 success 标志,以及如需的 error 对象。这让 UI 和服务器逻辑能处理相同响应类型,无需 try/catch。
使用 flatten 统一错误
Zod 返回复杂错误对象。表单只需两层:字段错误和全局错误。flatten() 方法完美契合:
export function formatZodError(error: import("zod").ZodError) {
const flat = error.flatten();
return {
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
示例区别:
- fieldErrors:标题字段不能为空、描述超过 500 字符。
- formError:此 slug 的笔记已存在、服务器暂时不可用。
这种分离让用户体验可预测:用户清楚哪里需修正输入,何时重试或等待。
客户端和服务器端的统一契约
客户端上,Zod 模式支持提前验证和 UI 状态管理:
const clientResult = useMemo(() => {
return noteSchema.pick({ title: true }).safeParse({ title });
}, [title]);
const titleError = clientResult.success
? null
: clientResult.error.flatten().fieldErrors.title?.[0] ?? null;
const canSubmit = clientResult.success;
服务器上,同一模式充当最终防线:
export async function createNoteAction(raw: unknown) {
const parsed = noteSchema.safeParse(raw);
if (!parsed.success) {
const flat = parsed.error.flatten();
return {
ok: false,
fieldErrors: flat.fieldErrors,
formError: flat.formErrors[0] ?? null,
};
}
try {
// save to database
return {
ok: true,
fieldErrors: {},
formError: null,
};
} catch {
return {
ok: false,
fieldErrors: {},
formError: "Not succeeded sokhranit zametku",
};
}
}
一条规则,不同角色:客户端管用户体验,服务器管安全。
标准表单状态形状
为避免碎片化,为所有表单定义单一类型:
export type FormState = {
ok: boolean;
fieldErrors: Record<string, string[] | undefined>;
formError: string | null;
};
这个形状覆盖所有状态:成功、字段错误、通用错误。项目中每个表单都返回相同结构——简化 UI 处理。
何时使用 React Hook Form
React Hook Form (RHF) 强大,但并非万能。只在以下表单中使用:
- 众多字段带依赖验证
- 动态列表或嵌套结构
- 失焦验证、已触碰/已脏状态
- 带焦点管理的自定义控件
- 需要渲染优化
对于 1–2 个字段的简单表单加基本验证,RHF 徒增复杂。如果 useMemo + Zod 派生状态能解决,就保持简单。
思维转变:从 UI 到契约
关键变化?别再把表单视为 UI 组件。表单是定义以下内容的契约:
- 有效数据模式
- 错误响应格式
- 状态行为(加载中、成功、错误)
- 客户端-服务器职责边界
契约先行,UI 实现就成机械活。最重要的是,项目中每个表单都按同一协议行事,行为可预测。
关键要点
- 客户端和服务器用同一 Zod 模式——消除规则分歧。
- 始终用
safeParse——返回易处理结果,而非异常。 - 分离
fieldErrors和formError——提升用户体验,简化显示逻辑。 - 定义标准
FormState形状——所有表单返回相同结构。 - 只在真正需要时加 React Hook Form——不是赶潮流,而是复杂场景。
— Editorial Team
暂无评论。