首页 / TanStack 生态入门教程 / 表单组合与数组字段

TanStack 生态入门教程

表单组合与数组字段

本教程共 38 篇 · 第 34 篇 · 更新于 2026-07-27 · 约 11 分钟阅读

TanStackTanStack 生态入门教程TanStack Form数组字段表单组合联动字段ListenersUI 库集成

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}].namei 是数组索引。
  • field.pushValue(item) 在末尾加一条。
  • field.removeValue(index) 删除指定索引的条目。
Note

数组字段的 name 路径语法支持嵌套:people[0].address.city 也行。TanStack Form 会正确解析路径,找到对应的数据。

34.1.3 数组字段的方法

除了 pushValueremoveValue,数组字段还有这些方法:

// 在末尾添加
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.AppFieldform.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 的组件有 valueonChange 属性,绑法类似:

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

注意 SelectonChange 直接返回值(不是事件对象),所以可以写 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,提交时 ageundefined。新条目要包含所有字段。

坑三:listeners 导致无限循环。 A 的 listeners.onChange 改 B,B 的 listeners.onChange 又改 A,死循环。加条件判断,值已经对了就不再改。

坑四:UI 库的 onChange 参数类型不同。 Ant Design 的 InputonChange 是事件对象,但 SelectonChange 直接是值。不同组件绑法不同,看文档确认。

坑五: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,从虚拟化原理入手。