本节目标:掌握用
useFieldArray管理可增删的字段行,理解 React key 与 RHF 内部字段标识的关系;学会用判别联合 schema 表达「选了 A 才出现 B」的条件字段,用superRefine写跨行校验;并弄清数组级错误与行级错误在errors树上的不同落点,以及分步表单如何只校验当前步。
13.3 复杂表单与动态字段
到这一节,表单开始脱离「一组固定输入框」的形态。订单明细可以加行删行、地址可以嵌在客户对象里、支付方式选「银行卡」时才需要卡号——这些结构一旦出现,静态的字段列表就不再够用,路径也从字符串常量变成了需要推导的模板类型。
13.3.1 动态字段要解决的三件事
| 问题 | 表现 | 本节工具 |
|---|---|---|
| 行数不固定 | 用户可以加行、删行、拖拽换序 | useFieldArray |
| 结构随取值变化 | 选了某个选项才出现一组字段 | 判别联合 schema |
| 校验依赖其它字段 | 名称不能重复、总额必须等于明细之和 | superRefine |
这三件事的共同点是:字段集合本身是运行时的。而 TypeScript 的类型是静态的,所以整个方案的关键在于——用「一个数组字段」和「一个判别联合字段」这两把静态的钥匙,去开动态的锁。
13.3.2 useFieldArray 的基本用法
先定义 schema。动态行在 schema 里就是一个普通的数组:
import { z } from 'zod';
const itemSchema = z.object({
name: z.string().min(1, '名称必填'),
qty: z.coerce.number().int('数量必须是整数').min(1, '数量至少为 1'),
});
export const orderSchema = z.object({
items: z.array(itemSchema).min(1, '至少添加一行明细'),
});
type S = typeof orderSchema;
表单侧用 useFieldArray 接管这个数组:
import { useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export function OrderForm() {
const { register, control, handleSubmit } = useForm<z.input<S>, any, z.output<S>>({
resolver: zodResolver(orderSchema),
defaultValues: { items: [{ name: '', qty: 1 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
return (
<form onSubmit={handleSubmit((v) => api.save(v))}>
{fields.map((field, index) => (
<fieldset key={field.id}>
<legend>第 {index + 1} 行</legend>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.qty`)} />
<button type="button" onClick={() => remove(index)}>删除</button>
</fieldset>
))}
<button type="button" onClick={() => append({ name: '', qty: 1 })}>添加一行</button>
<button>提交</button>
</form>
);
}
三个细节值得停一下:
name: 'items'必须与 schema 里的数组字段逐字一致,写错不会报错,只是fields永远是空数组。defaultValues.items至少要给一行。不给的话fields初始为空、.min(1)的错误立刻挂在根上,用户看到的是一片空白加一条报错。register的路径用模板字符串拼下标,FieldPath里已经预置了`items.${number}.name`这样的模板类型,所以不用手写类型断言(老版本 TS 上可能需要补一个as const)。
13.3.3 稳定 key:为什么不能用 index
上面代码里 key={field.id},id 是 RHF 给每一行生成的稳定标识。如果按 React 的惯性写成 key={index},会踩到一个很难查的 bug:
// 反例:删除第 0 行后,原本第 1 行的 DOM 节点被复用为第 0 行
{fields.map((field, index) => <input key={index} {...register(`items.${index}.name`)} />)}
原因在于 RHF 用 ref 把 DOM 节点与「字段路径」绑定在一起。删掉第 0 行后,register('items.0.name') 的 ref 换了一个 DOM 节点,而 React 因为 key 没变,复用了原来的节点——输入框里显示的文本和它实际归属的字段就对不上了。用户会看到「删掉第一行,第二行的内容跳上来了」这类诡异现象。
useFieldArray 返回的每个 field 都带 id(可以用 keyName 改名字):
const { fields } = useFieldArray({ control, name: 'items', keyName: 'fieldId' });
// fields[i].fieldId 是稳定 id,用它做 key
一条纪律:fields 里的 id 只用来做 key,不要提交给后端。它不是表单值,handleSubmit 拿到的 values 里没有它。
13.3.4 增删改与常用方法
useFieldArray 返回的方法相当完整,日常用到的有:
| 方法 | 作用 | 注意 |
|---|---|---|
append(value) | 末尾追加 | 可传数组一次加多行 |
prepend(value) | 头部插入 | 会让所有下标后移 |
insert(index, value) | 指定位置插入 | |
remove(index) | 删除 | 不传参则清空全部 |
swap(a, b) | 交换两行 | 保留下标,只换值 |
move(from, to) | 移动一行 | 拖拽排序用这个 |
update(index, value) | 替换整行 | 会重置该行所有字段 |
replace(values) | 整体替换 | 服务端回填时用 |
swap 与 move 的区别值得留意:swap 只交换两个位置的值,move 是「把某行插到另一处、其余顺移」。做拖拽排序时用 move,做「上移/下移」按钮时两者都行。
remove 之后不要手动去改 defaultValues 或调用 reset,那会把用户其它字段的输入一并清掉。useFieldArray 已经同步好了内部状态。
13.3.5 嵌套数组与路径
数组可以嵌套:明细行里再放一个「子项」数组。此时 useFieldArray 的 name 写完整路径:
const sub = useFieldArray({ control, name: `items.${index}.subitems` });
对应的 schema 也是嵌套的,错误路径随之变长:
z.object({
items: z.array(z.object({
name: z.string(),
subitems: z.array(z.object({ label: z.string().min(1, '子项名必填') })),
})),
});
// 错误路径:items.0.subitems.2.label
路径一深,读取错误就不能再手写点号串了。前面 13.2 介绍过的 get 在这里是必需品:
import { get, type FieldErrors } from 'react-hook-form';
const msg = get(errors, `items.${index}.subitems.${si}.label`)?.message;
get 会沿着路径逐层做可选链,任何一层缺失都安全返回 undefined,不会像 errors.items[0].subitems[2].label.message 那样在中间断掉时抛异常。
13.3.6 数组级错误与行级错误的落点
这是动态字段里最容易踩的一处。z.array(itemSchema).min(1) 这条规则针对的是数组本身,它的 issue.path 是 ['items'];而某一行的错误路径是 ['items', 0, 'name']。两者在 errors 树上的形状不一样。
实测 zodResolver + RHF 7 的三种情形:
| 情形 | errors.items 的实际结构 | 读取方式 |
|---|---|---|
| 只有数组级错误 | { type, message } | errors.items?.message |
| 只有行级错误 | [{ name: { type, message } }] | errors.items?.[0]?.name?.message |
| 两者同时存在 | { 0: {...}, root: { type, message } } | 行级照旧,数组级用 errors.items?.root?.message |
也就是说,数组级错误的落点会随行级错误是否存在而变化:没有行级错误时它直接挂在 items 上,一旦有行级错误,它就被挪到 items.root。原因是 resolvers 会先判断该字段是不是「数组字段」(是否存在形如 items.0 的键),是的话就把数组级错误放进 root。
这种「同一个错误有两种读法」的情况,不要在每个组件里各写一遍 if。抽一个函数兜住:
import { get, type FieldErrors, type FieldValues } from 'react-hook-form';
export function arrayRootError<T extends FieldValues>(errors: FieldErrors<T>, name: string) {
const node = get(errors, name) as { root?: { message?: string }; message?: string } | undefined;
if (!node || Array.isArray(node)) return undefined;
return node.root?.message ?? node.message;
}
用法就是 arrayRootError(errors, 'items')。Array.isArray(node) 那一支返回 undefined 是刻意的:只有行级错误时,数组本身没有错误,返回空才符合语义。
13.3.7 条件字段:判别联合 schema
「选了银行卡才要卡号」这类需求,正确的建模方式是判别联合(discriminated union)——用一个字面量字段作为判别键,Zod 会自动只校验命中的那支:
const paymentSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('card'), number: z.string().regex(/^\d{16}$/, '卡号需 16 位数字') }),
z.object({ type: z.literal('bank'), iban: z.string().min(15, 'IBAN 至少 15 位') }),
z.object({ type: z.literal('cod'), remark: z.string().max(50).optional() }),
]);
用 useWatch 读出当前判别键,据此渲染对应分支:
import { useWatch, type Control } from 'react-hook-form';
function PaymentFields({ control }: { control: Control<z.input<S>, any, z.output<S>> }) {
const type = useWatch({ control, name: 'payment.type' });
if (type === 'card') return <input {...register('payment.number')} />;
if (type === 'bank') return <input {...register('payment.iban')} />;
return null;
}
这里有一个必须交代的坑:切换分支后,旧分支的字段值还留在表单里。用户先填了卡号,再切到银行转账,提交时 payment.number 仍在 values 中——而判别联合的 schema 只保留命中分支的键(strip 模式会丢弃多余键),所以 Zod 会把它删掉。但这依赖 schema 的剥离行为,更稳妥的做法是显式声明:
const form = useForm<z.input<S>, any, z.output<S>>({
resolver: zodResolver(paymentSchema),
shouldUnregister: true, // 组件卸载时同步移除对应字段值
});
shouldUnregister: true 的语义是「字段从界面上消失,值也从表单里消失」。它正好匹配条件字段的直觉。代价是:如果只是暂时隐藏(比如折叠面板),用户重新展开时输入会丢失——这时应该用 false(默认),并在提交前靠 schema 剥离多余键。
13.3.8 跨行校验:superRefine
「明细名称不能重复」「总额必须等于各行小计之和」这类规则跨越了多个字段,refine 挂在对象上拿不到行下标,superRefine 挂在数组上则可以逐行 addIssue 并指定 path:
export const orderSchema = z.object({
items: z.array(itemSchema).min(1, '至少添加一行明细').superRefine((items, ctx) => {
const seen = new Map<string, number>();
items.forEach((item, i) => {
const key = item.name.trim().toLowerCase();
if (key && seen.has(key)) {
ctx.addIssue({
code: 'custom',
path: [i, 'name'], // 关键:挂到具体行,而不是整个数组
message: `与第 ${(seen.get(key) ?? 0) + 1} 行重复`,
});
} else if (key) {
seen.set(key, i);
}
});
}),
});
path: [i, 'name'] 里的 i 是数组内的下标,Zod 会自动把它与上层的 items 拼起来,最终落到 errors.items[i].name。如果不给 path,错误会挂在数组根上,用户只看到「表单有问题」却找不到是哪一行——和 13.1 里 refine 缺 path 是同一个错误。
顺带一提,superRefine 里抛出的 code: 'custom' 会原样成为 error.type,前端可以据此区分「字段级规则」与「业务级规则」,做出不同的展示(例如后者用黄色警告而非红色错误)。
13.3.9 分步表单与草稿持久化
分步表单的核心诉求是:「下一步」只校验当前步的字段,而不是整表校验。RHF 的 trigger 支持按路径列表触发:
const stepFields: FieldPath<z.input<S>>[][] = [
['customer.name', 'customer.phone'],
['items.0.name'], // 数组字段用具体下标
['payment.type'],
];
const next = async () => {
const ok = await trigger(stepFields[step]);
if (ok) setStep((s) => s + 1);
};
注意 trigger 返回的是 Promise<boolean>,必须 await,否则会在校验完成前就切步。数组字段的路径需要写具体下标(如 items.0.name),想校验整个数组就直接写 'items'。
草稿持久化让用户刷新页面不丢输入,思路是「订阅全表 + 防抖写 localStorage + 挂载时回填」:
useEffect(() => {
const saved = localStorage.getItem('order-draft');
if (saved) reset(JSON.parse(saved)); // reset 才能让 defaultValues 之外的初始值生效
}, [reset]);
const values = watch();
useEffect(() => {
const t = setTimeout(() => localStorage.setItem('order-draft', JSON.stringify(values)), 500);
return () => clearTimeout(t);
}, [values]);
两个要点:一是回填必须用 reset,改 defaultValues 无效(它只在首次挂载时读取);二是 watch() 无参调用会订阅整个表单,每次按键都触发一次 effect,所以防抖不可省,且这个 effect 应当只存在于顶层的草稿容器组件里。
13.3.10 性能与无障碍
动态字段的行数没有上限,所以性能不能靠「先写完再说」。三条实用约束:
| 做法 | 收益 |
|---|---|
用 useWatch({ name }) 而非顶层 watch() 读值 | 重渲染限制在订阅字段的子组件 |
把每一行抽成 memo 组件,只传 control 与 index | 加一行不会重渲染其余行 |
避免在行组件里解构整个 formState | 只解构该行需要的 errors 项 |
无障碍方面,动态字段比静态表单更需要结构:
<fieldset aria-describedby={errors.items?.[index]?.name ? `item-${index}-name-err` : undefined}>
<legend>第 {index + 1} 行明细</legend>
<label htmlFor={`item-${index}-name`}>名称</label>
<input id={`item-${index}-name`} {...register(`items.${index}.name`)} />
<p id={`item-${index}-name-err`} role="alert">
{errors.items?.[index]?.name?.message}
</p>
</fieldset>
fieldset + legend 让屏幕阅读器知道「第 N 行」的归属,aria-describedby 把错误文案关联到输入框,role="alert" 让错误出现时被主动播报。删除按钮要写清 aria-label={\删除第 ${index + 1} 行`}`,否则读屏软件只念出一个孤零零的「删除」。
13.3.11 五个常见坑
一、用 index 当 React key。 删除后 DOM 节点复用,输入内容与字段错位。必须用 field.id。
二、useFieldArray 的 name 与 schema 字段名不一致。 不报错,fields 恒为空,页面上一行都渲染不出来。
三、defaultValues 里没给数组初始值。 fields 初始为空,.min(1) 的错误立刻出现,用户一进来就看到报错。
四、数组级错误只读 errors.items?.message。 一旦有行级错误,数组级错误会被挪到 root,读取落空。用 13.3.6 的 arrayRootError 兜住两种形状。
五、superRefine 不给 path。 跨行校验的错误全挂在数组根上,用户找不到是哪一行。
13.3.12 与其它章节的衔接
基础写法(register / Controller / zodResolver)见 《TypeScript编程实战》13.1 React Hook Form + Zod
;z.input / z.output 的分叉与错误映射规则见 《TypeScript编程实战》13.2 表单类型推导与错误映射
。提交成功后的缓存失效与乐观更新见 《TypeScript编程实战》14.2 乐观更新与缓存失效
;同一份 schema 在服务端复用见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono)
;端到端类型安全见 《TypeScript编程实战》16.1 tRPC 端到端类型安全
。
站内延伸阅读:前端表单与校验架构 、运行时类型校验与类型安全 、TypeScript 与 Zod 运行时校验 、低代码 Schema 表单引擎 、前端无障碍实践 、E2E 测试与 Playwright 。
小结
动态字段的难点不在 API,而在「运行时结构」与「静态类型」之间的翻译。useFieldArray 把一个数组字段变成可增删的行集合,代价是必须遵守它的两条纪律:name 与 schema 字段名逐字一致、React key 用 RHF 生成的 id 而非下标。嵌套越深,错误读取越不能手写点号串,get 是必需品。
条件字段用判别联合 schema 表达,Zod 只校验命中的分支;切换分支后旧值是否残留,取决于 shouldUnregister 与 schema 的剥离行为,前者适合「字段真的消失」,后者适合「只是暂时隐藏」。跨行与跨字段规则交给 superRefine,一定要给 path,否则错误无处可挂。数组级错误的落点会在 items 与 items.root 之间摇摆,用一个小函数兜住两种形状比在每个组件里判断更可靠。
分步表单用 trigger(paths) 只校验当前步,草稿持久化用 reset 回填、用防抖写盘。至此第十三章结束:从 register 到 zodResolver,从 z.input/z.output 的分叉到动态字段的路径推导,表单这条线上「状态、规则、类型」三份副本终于收敛成了一份 schema。下一章换到数据的另一侧——当表单提交之后,服务端状态如何被缓存、失效与乐观更新,TanStack Query 会接手这段旅程。
阅读导航:上一节:13.2 表单类型推导与错误映射 · 下一节:14.1 TanStack Query 类型推导 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。