表单组合与数组字段
本教程共 38 篇 · 第 34 篇 · 更新于 2026-07-27 · 约 11 分钟阅读
34. 表单组合与数组字段
本节目标:掌握 TanStack Form 的进阶用法。学会用 createFormHook 复用表单逻辑、用数组字段动态增删条目、做字段联动、用 listeners 监听字段变化、与 UI 组件库集成。学完你能搭出复杂动态表单。
34.1 数组字段(Array Fields)
表单里经常有「动态列表」—用户可以添加或删除条目。比如填简历时的工作经历,可以加多条,每条有公司名、职位、时间。
TanStack Form 用 mode="array" 来处理数组字段。
34.1.1 定义数组字段
在 defaultValues 里把字段类型设为数组:
const form = useForm({
defaultValues: {
people: [
{ name: '', age: 0 },
],
},
onSubmit: async ({ value }) => console.log(value),
})
34.1.2 渲染数组字段
用 mode="array" 告诉 Field 这是一个数组字段:
<form.Field name="people" mode="array">
{(field) => (
<div>
{/* 遍历数组,每项渲染一个子表单 */}
{field.state.value.map((person, i) => (
<div key={i} className="border p-3 mb-2 flex gap-2">
{/* 子字段用 people[${i}].name 的路径 */}
<form.Field name={`people[${i}].name`}>
{(subField) => (
<input
value={subField.state.value}
onBlur={subField.handleBlur}
onChange={(e) => subField.handleChange(e.target.value)}
placeholder="姓名"
/>
)}
</form.Field>
<form.Field name={`people[${i}].age`}>
{(subField) => (
<input
type="number"
value={subField.state.value}
onBlur={subField.handleBlur}
onChange={(e) => subField.handleChange(Number(e.target.value))}
placeholder="年龄"
/>
)}
</form.Field>
{/* 删除按钮 */}
<button
type="button"
onClick={() => field.removeValue(i)}
className="text-red-500"
>
删除
</button>
</div>
))}
{/* 添加按钮 */}
<button
type="button"
onClick={() => field.pushValue({ name: '', age: 0 })}
className="bg-blue-500 text-white px-3 py-1 rounded"
>
添加人员
</button>
</div>
)}
</form.Field>
关键点:
- 父 Field 设
mode="array",拿到数组操作方法。 - 子字段的
name用路径语法people[${i}].name,i是数组索引。 field.pushValue(item)在末尾加一条。field.removeValue(index)删除指定索引的条目。
Note数组字段的
name路径语法支持嵌套:people[0].address.city也行。TanStack Form 会正确解析路径,找到对应的数据。
34.1.3 数组字段的方法
除了 pushValue 和 removeValue,数组字段还有这些方法:
// 在末尾添加
field.pushValue({ name: '', age: 0 })
// 在开头插入
field.insertValue(0, { name: '', age: 0 })
// 删除指定索引
field.removeValue(2)
// 交换两个位置
field.swapValues(0, 1)
// 把某项移到另一个位置
field.moveValue(0, 2)
// 替换指定索引的值
field.replaceValue(0, { name: '新名字', age: 20 })
Tip用
swapValues做上移/下移功能很方便。用insertValue在中间插入条目。别用splice直接改数组,要走 Form 的 API 才能正确更新状态。
34.2 字段联动(Linked Fields)
字段联动是表单的常见需求:A 字段的值变了,B 字段的选项或校验跟着变。
比如:选了省份后,城市下拉框的选项变成该省的城市。
34.2.1 用 listeners 监听变化
TanStack Form 提供了 listeners 属性,让字段响应其他字段的变化:
const form = useForm({
defaultValues: {
province: '',
city: '',
},
onSubmit: async ({ value }) => console.log(value),
})
const cityMap: Record<string, string[]> = {
广东: ['广州', '深圳', '珠海'],
浙江: ['杭州', '宁波', '温州'],
江苏: ['南京', '苏州', '无锡'],
}
// 省份字段变化时,清空城市字段
<form.Field name="province">
{(field) => (
<div>
<label>省份</label>
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">请选择</option>
<option value="广东">广东</option>
<option value="浙江">浙江</option>
<option value="江苏">江苏</option>
</select>
</div>
)}
</form.Field>
{/* 城市字段:监听省份变化 */}
<form.Field
name="city"
listeners={{
// 省份变化时触发
onChange: ({ fieldApi }) => {
const province = fieldApi.form.getFieldValue('province')
const cities = cityMap[province] || []
// 如果当前城市不在新省份的城市列表里,清空
const currentCity = fieldApi.state.value
if (currentCity && !cities.includes(currentCity)) {
fieldApi.handleChange('')
}
},
}}
>
{(field) => {
const province = form.getFieldValue('province')
const cities = cityMap[province] || []
return (
<div>
<label>城市</label>
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
disabled={!province}
>
<option value="">请选择</option>
{cities.map((city) => (
<option key={city} value={city}>{city}</option>
))}
</select>
</div>
)
}}
</form.Field>
listeners 里的 onChange 不是字段自己的值变化时触发,而是监听整个表单的变化。你可以在里面读其他字段的值,做联动逻辑。
Warning
listeners.onChange会在表单任何值变化时触发,要注意别写成无限循环—A 改 B 的值,B 的变化又触发 A 改值。只在需要联动时才改其他字段的值。
34.3 表单组合与复用
表单字段多了,全写在一个组件里又长又难维护。TanStack Form 支持表单组合(Form Composition),把字段拆到子组件里。
34.3.1 用 form.Field 拆分
子组件接收 form 实例,自己渲染字段:
// 子组件:姓名字段组
function NameFields({ form }) {
return (
<>
<form.Field name="firstName">
{(field) => (
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="名"
/>
)}
</form.Field>
<form.Field name="lastName">
{(field) => (
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="姓"
/>
)}
</form.Field>
</>
)
}
// 主表单
function MyForm() {
const form = useForm({
defaultValues: { firstName: '', lastName: '', email: '' },
onSubmit: async ({ value }) => console.log(value),
})
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
<NameFields form={form} />
<form.Field name="email">
{(field) => (
<input
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="邮箱"
/>
)}
</form.Field>
<button type="submit">提交</button>
</form>
)
}
把 form 实例传给子组件,子组件就能用 form.Field 渲染字段。这种方式简单直接,字段多时拆分很方便。
34.3.2 用 createFormHook 复用
如果多个表单共享同样的字段组件和校验逻辑,用 createFormHook 创建可复用的表单 Hook:
import { createFormHook } from '@tanstack/react-form'
// 定义可复用的字段组件
const TextField = ({ field, label }) => (
<div>
<label>{label}</label>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)
// 注册字段组件
const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
},
fieldValidatorLibrary: undefined, // 可以接 Zod/Valibot 等 schema 库
})
// 在组件中使用
function MyForm() {
const form = useAppForm({
defaultValues: { name: '', email: '' },
onSubmit: async ({ value }) => console.log(value),
})
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
<form.AppField name="name" component={TextField} label="姓名" />
<form.AppField name="email" component={TextField} label="邮箱" />
<button type="submit">提交</button>
</form>
)
}
form.AppField 是 form.Field 的增强版,支持用 component 属性直接指定字段组件,不用写 render prop。这在表单多、字段组件复用度高的场景下很省代码。
Tip
createFormHook适合做一套表单组件库的场景。如果只有一两个表单,直接传form实例给子组件就够了,不需要这么重的抽象。
34.4 与 UI 库集成
TanStack Form 是 headless 的,不管 UI。和 Ant Design、Material UI、shadcn/ui 等组件库集成,只需要把字段值和事件绑到组件库的组件上。
34.4.1 和原生组件集成
// 和原生 input 集成
<form.Field name="username">
{(field) => (
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>
// 和原生 select 集成
<form.Field name="country">
{(field) => (
<select
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
<option value="">请选择</option>
<option value="cn">中国</option>
<option value="us">美国</option>
</select>
)}
</form.Field>
34.4.2 和 Ant Design 集成
Ant Design 的组件有 value 和 onChange 属性,绑法类似:
import { Input, Select } from 'antd'
<form.Field name="username">
{(field) => (
<Input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>
<form.Field name="country">
{(field) => (
<Select
value={field.state.value}
onChange={(value) => field.handleChange(value)}
options={[
{ value: 'cn', label: '中国' },
{ value: 'us', label: '美国' },
]}
/>
)}
</form.Field>
Note注意
Select的onChange直接返回值(不是事件对象),所以可以写onChange={(value) => field.handleChange(value)},不用e.target.value。不同 UI 库的 API 不一样,绑的时候看一下文档。
34.4.3 封装适配组件
每个字段都写一遍 value/onChange/onBlur 的绑定很重复。可以封装一个适配组件:
// 通用的字段适配器
function FormInput({ field, label, type = 'text' }) {
return (
<div>
<label className="block text-sm mb-1">{label}</label>
<input
type={type}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className="border rounded px-3 py-2 w-full"
/>
{field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
<span className="text-red-500 text-sm">{field.state.meta.errors[0]}</span>
)}
</div>
)
}
// 使用:一行搞定
<form.Field name="email">
{(field) => <FormInput field={field} label="邮箱" type="email" />}
</form.Field>
这样每个字段只需要一行渲染,错误提示也统一了。
34.5 完整的动态表单示例
import { useForm } from '@tanstack/react-form'
type Person = { name: string; age: number; hobbies: string[] }
function DynamicForm() {
const form = useForm({
defaultValues: {
people: [{ name: '', age: 0, hobbies: [] }] as Person[],
},
onSubmit: async ({ value }) => {
console.log('提交:', JSON.stringify(value, null, 2))
},
})
return (
<form
onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}
className="max-w-2xl space-y-4"
>
<form.Field name="people" mode="array">
{(field) => (
<div className="space-y-4">
{field.state.value.map((_, i) => (
<div key={i} className="border rounded p-4 space-y-2">
<div className="flex gap-2">
<form.Field name={`people[${i}].name`}>
{(sub) => (
<input
value={sub.state.value}
onBlur={sub.handleBlur}
onChange={(e) => sub.handleChange(e.target.value)}
placeholder="姓名"
className="border px-2 py-1 rounded"
/>
)}
</form.Field>
<form.Field name={`people[${i}].age`}>
{(sub) => (
<input
type="number"
value={sub.state.value}
onBlur={sub.handleBlur}
onChange={(e) => sub.handleChange(Number(e.target.value))}
placeholder="年龄"
className="border px-2 py-1 rounded w-20"
/>
)}
</form.Field>
<button
type="button"
onClick={() => field.removeValue(i)}
className="text-red-500 px-2"
>
删除
</button>
</div>
{/* 爱好:嵌套数组字段 */}
<form.Field name={`people[${i}].hobbies`} mode="array">
{(hobbyField) => (
<div className="flex gap-1 flex-wrap">
{hobbyField.state.value.map((_, j) => (
<form.Field key={j} name={`people[${i}].hobbies[${j}]`}>
{(sub) => (
<input
value={sub.state.value}
onBlur={sub.handleBlur}
onChange={(e) => sub.handleChange(e.target.value)}
placeholder="爱好"
className="border px-2 py-1 rounded text-sm w-24"
/>
)}
</form.Field>
))}
<button
type="button"
onClick={() => hobbyField.pushValue('')}
className="text-blue-500 text-sm"
>
+ 爱好
</button>
</div>
)}
</form.Field>
</div>
))}
<button
type="button"
onClick={() => field.pushValue({ name: '', age: 0, hobbies: [] })}
className="bg-blue-500 text-white px-3 py-1 rounded"
>
添加人员
</button>
</div>
)}
</form.Field>
<button type="submit" className="bg-green-500 text-white px-4 py-2 rounded">
提交
</button>
</form>
)
}
这个表单支持动态添加/删除人员,每个人还有动态爱好列表,嵌套了两层数组字段。
34.6 常见坑
坑一:数组字段的 key 用索引。 用 key={i} 在删除中间项时会导致 React 复用错误的 DOM,输入框内容错位。应该用数据的唯一 id 当 key。如果数据没有 id,用 field.state.value 的某种唯一标识。
坑二:pushValue 传了不完整的数据。 pushValue({ name: '' }) 漏了 age,提交时 age 是 undefined。新条目要包含所有字段。
坑三:listeners 导致无限循环。 A 的 listeners.onChange 改 B,B 的 listeners.onChange 又改 A,死循环。加条件判断,值已经对了就不再改。
坑四:UI 库的 onChange 参数类型不同。 Ant Design 的 Input 的 onChange 是事件对象,但 Select 的 onChange 直接是值。不同组件绑法不同,看文档确认。
坑五:createFormHook 用得太早。 项目只有一两个表单就上 createFormHook,增加了理解成本。先简单传 form 实例,等字段组件复用度高了再抽象。
34.7 小结
这一章讲了 TanStack Form 的进阶用法:
- 数组字段:
mode="array",用pushValue/removeValue/insertValue/swapValues操作数组,子字段用array[${i}].field路径。 - 字段联动:用
listeners监听表单变化,在回调里读其他字段值做联动逻辑。 - 表单组合:把
form实例传给子组件,子组件用form.Field渲染字段。 - createFormHook:注册可复用字段组件,用
form.AppField+component简化字段渲染。 - UI 库集成:绑
value/onChange/onBlur到 UI 组件,注意不同组件的参数类型差异。
Form 三章到此结束。下一章开始讲 TanStack Virtual,从虚拟化原理入手。