首页 / TanStack 生态入门教程 / Form 入门:表单状态与 Field API

TanStack 生态入门教程

Form 入门:表单状态与 Field API

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

TanStackTanStack 生态入门教程TanStack FormuseFormField表单状态Field API

32. Form 入门:表单状态与 Field API

本节目标:学会安装 TanStack Form,用 useForm 创建表单实例、用 Field 组件绑定字段、理解 field API 和 form state,实现一个能提交的完整表单。学完你能搭出类型安全的 React 表单。

32.1 为什么需要表单库

React 做表单,最朴素的方式是 useState 管每个字段:

const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [age, setAge] = useState(0)
// 字段一多,useState 就爆炸了

字段少时没问题,但真实表单动辄十几个字段,还涉及校验、错误提示、提交处理、动态字段、联动逻辑… 纯 useState 写起来又长又乱。

表单库就是帮你把这些逻辑封装好的工具。TanStack Form 的特点是:

  • 类型安全:字段值的类型从 defaultValues 自动推导,填错类型直接报红。
  • Field 组件化:每个字段用 <Field> 组件包裹,状态、校验、错误信息集中管理。
  • headless 设计:和 Table 一样不管 UI,你用什么组件库都行。
  • 异步校验原生支持:内置异步校验和防抖。
Note

本教程基于 TanStack Form v1(当前稳定版 1.33.x)。v1 是第一个稳定大版本,API 已稳定。

32.2 安装

npm install @tanstack/react-form

如果要用 Schema 校验(推荐),再装对应的 schema 库:

# Zod(最流行)
npm install zod

# 或 Valibot(更轻量)
npm install valibot
Tip

Schema 校验后面第 33 章专门讲。这一章先用最朴素的手写校验,理解 Form 的基本流程。

32.3 useForm:表单的大脑

useForm 是 TanStack Form 的核心 Hook,创建一个表单实例(Form Instance)

import { useForm } from '@tanstack/react-form'

function MyForm() {
  const form = useForm({
    defaultValues: {
      firstName: '',
      lastName: '',
      email: '',
      age: 0,
    },
    onSubmit: async ({ value }) => {
      // 提交时拿到的 value 就是整个表单的数据
      console.log(value)
      // { firstName: '张', lastName: '三', email: 'zs@test.com', age: 28 }
    },
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      form.handleSubmit()
    }}>
      {/* 字段在这里 */}
      <button type="submit">提交</button>
    </form>
  )
}

两个必填配置:

  • defaultValues:表单的初始值。所有字段都要在这里声明,后面 <Field>name 要和这里的 key 对应。类型也从这里推导。
  • onSubmit:提交回调,拿到完整的表单数据。通常是异步的(发请求到服务端)。
Warning

defaultValues 里的字段类型决定了整个表单的类型系统。age: 0 推导出 numberage: '' 推导出 string。初始值要和实际数据类型一致,不然后面校验和提交会出问题。

32.4 Field 组件:绑定单个字段

form.Field 是一个组件,把表单实例和某个字段关联起来。每个输入框都用一个 <Field> 包裹。

<form.Field name="firstName">
  {(field) => (
    <div>
      <label>名</label>
      <input
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
    </div>
  )}
</form.Field>

拆解一下:

32.4.1 name 属性

name 对应 defaultValues 里的 key。写错了 TypeScript 会报错:

// 正确
<form.Field name="firstName">

// 错误:defaultValues 里没有 firstName2
<form.Field name="firstName2">  // TS 报红

32.4.2 render prop 模式

Field 用的是 render prop 模式—传一个函数作为子元素,函数参数是 field 对象。

<form.Field name="firstName">
  {(field) => (
    // field 里有状态和操作方法
    <input ... />
  )}
</form.Field>

field 对象是字段实例,包含状态和操作方法。

32.4.3 field 的核心 API

{(field) => {
  // === 状态 ===
  field.state.value       // 当前值
  field.state.meta.isTouched  // 是否被触碰过(失焦过)
  field.state.meta.isDirty    // 值是否被改过
  field.state.meta.errors     // 校验错误数组

  // === 操作 ===
  field.handleChange(newValue)  // 改值
  field.handleBlur              // 失焦处理器
  field.handleSubmit            // 提交处理器

  return <input ... />
}}

最常用的三个:

  • field.state.value — 绑到 inputvalue
  • field.handleChange — 绑到 inputonChange,传入新值(不是事件对象)。
  • field.handleBlur — 绑到 inputonBlur
Note

注意 field.handleChange 接收的是,不是事件对象。所以要写 onChange={(e) => field.handleChange(e.target.value)},不能直接写 onChange={field.handleChange}。这点和原生 React 表单不一样,新手容易搞混。

32.5 一个完整的表单

把上面的组合起来,写一个注册表单:

import { useForm } from '@tanstack/react-form'

function RegisterForm() {
  const form = useForm({
    defaultValues: {
      firstName: '',
      lastName: '',
      email: '',
      age: 0,
    },
    onSubmit: async ({ value }) => {
      // 模拟提交到服务端
      await new Promise((resolve) => setTimeout(resolve, 1000))
      console.log('提交的数据:', value)
      alert('注册成功!')
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
      className="max-w-md space-y-4"
    >
      {/* 名 */}
      <form.Field name="firstName">
        {(field) => (
          <div>
            <label className="block text-sm font-medium mb-1">名</label>
            <input
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              className="border rounded px-3 py-2 w-full"
            />
          </div>
        )}
      </form.Field>

      {/* 姓 */}
      <form.Field name="lastName">
        {(field) => (
          <div>
            <label className="block text-sm font-medium mb-1">姓</label>
            <input
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              className="border rounded px-3 py-2 w-full"
            />
          </div>
        )}
      </form.Field>

      {/* 邮箱 */}
      <form.Field name="email">
        {(field) => (
          <div>
            <label className="block text-sm font-medium mb-1">邮箱</label>
            <input
              type="email"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              className="border rounded px-3 py-2 w-full"
            />
          </div>
        )}
      </form.Field>

      {/* 年龄 */}
      <form.Field name="age">
        {(field) => (
          <div>
            <label className="block text-sm font-medium mb-1">年龄</label>
            <input
              type="number"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(Number(e.target.value))}
              className="border rounded px-3 py-2 w-full"
            />
          </div>
        )}
      </form.Field>

      <button
        type="submit"
        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
      >
        提交
      </button>
    </form>
  )
}

注意年龄字段:onChange 里要把字符串转成数字 Number(e.target.value),因为 defaultValuesagenumber 类型,field.handleChange 期望接收 number。不转的话 TypeScript 会报错。

32.6 表单状态(Form State)

表单实例也有自己的状态,通过 form.state 访问:

// 表单整体状态
form.state.isSubmitting     // 是否正在提交
form.state.isValid          // 是否全部字段校验通过
form.state.canSubmit        // 能否提交
form.state.isDirty          // 表单值是否被改过
form.state.isTouched        // 是否有字段被触碰过
form.state.values           // 整个表单的当前值(和 onSubmit 里的 value 一样)

用这些状态做 UI 反馈:

<button
  type="submit"
  disabled={form.state.isSubmitting || !form.state.canSubmit}
>
  {form.state.isSubmitting ? '提交中...' : '提交'}
</button>

{form.state.isDirty && (
  <button type="button" onClick={() => form.reset()}>
    重置
  </button>
)}
Tip

form.state.isSubmittingonSubmit 是异步函数时很有用。提交期间按钮禁用,防止重复提交。

32.7 字段状态详解

每个 field 也有自己的 state,除了 value 还有 meta

field.state.value          // 字段当前值
field.state.meta.isTouched // 是否被触碰过(失焦过一次以上)
field.state.meta.isDirty   // 值是否和初始值不同
field.state.meta.isPristine // 是否从未被改过(isDirty 的反面)
field.state.meta.errors    // 校验错误数组
field.state.meta.errorMap  // 按校验时机分组的错误

这些状态常用来控制 UI:

<form.Field name="email">
  {(field) => (
    <div>
      <label>邮箱</label>
      <input
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      {/* 只在触碰过且有错误时才显示错误 */}
      {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>

isTouched 的作用是「不要一上来就报错」。用户还没碰过这个字段就显示错误,体验很差。等用户失焦过一次(isTouched 变 true)才显示校验错误,这是表单交互的基本原则。

Note

errorserrorMap 的区别:errors 是所有错误的扁平数组,errorMaponChange/onBlur/onSubmit 等时机分组。一般用 errors 就够了。

32.8 表单的常用方法

除了 handleSubmitreset,表单实例还有一些常用方法:

// 提交
form.handleSubmit()

// 重置到默认值
form.reset()

// 设置某个字段的值
form.setFieldValue('firstName', '新名字')

// 获取某个字段的值
form.getFieldValue('firstName')

// 验证整个表单
await form.validate()

// 订阅表单状态变化(性能优化用)
form.subscribe((state) => {
  console.log('表单状态变了:', state.isSubmitting)
})

32.9 订阅与性能优化

默认情况下,表单状态变化会触发整个表单组件重新渲染。字段多时可能有性能问题。

form.Subscribe 组件让你只订阅关心的状态,减少不必要的重渲染:

import { useForm } from '@tanstack/react-form'

<form.Subscribe selector={(state) => state.isSubmitting}>
  {(isSubmitting) => (
    <button type="submit" disabled={isSubmitting}>
      {isSubmitting ? '提交中...' : '提交'}
    </button>
  )}
</form.Subscribe>

selector 函数指定要订阅的状态切片。只有这个切片变了,Subscribe 内部的内容才会重新渲染。上面的例子中,只有 isSubmitting 变化时按钮才更新,其他字段输入不会导致按钮重渲染。

Warning

不用 Subscribe 时,每个字段的输入都会触发整个表单组件重渲染。字段少时无所谓,十几个字段以上建议用 Subscribe 把提交按钮等独立部分包起来。

32.10 常见坑

坑一:field.handleChange 传了事件对象。 handleChange 要的是值,不是事件。onChange={field.handleChange} 会把整个事件对象当值存进去。要写 onChange={(e) => field.handleChange(e.target.value)}

坑二:数字字段忘了类型转换。 input type="number"e.target.value 是字符串。age 字段如果 defaultValues 里是 number,不 Number() 转换会类型不匹配。

坑三:defaultValues 里漏了字段。 <Field name="phone">defaultValues 里没有 phone,TypeScript 报错。所有字段都要在 defaultValues 里声明初始值。

坑四:提交按钮放 <form> 外面。 提交按钮要在 <form> 标签内部,且 type="submit"。放外面或 type 写错,点按钮不会触发 form.handleSubmit()

坑五:一上来就显示校验错误。 没判断 isTouched 就显示 errors,页面刚加载全是红的。校验错误要在 isTouchedtrue 后才显示。

32.11 小结

这一章你认识了 TanStack Form:

  • useForm:创建表单实例,defaultValues 定义字段和类型,onSubmit 处理提交。
  • Field 组件:用 render prop 模式绑定字段,name 对应 defaultValues 的 key。
  • field APIstate.value 绑值、handleChange 改值、handleBlur 处理失焦。
  • 表单状态isSubmitting/isValid/isDirty 等,控制提交按钮和重置按钮。
  • 字段状态isTouched/isDirty/errors,控制错误提示的显示时机。
  • Subscribe 组件:按需订阅状态切片,优化性能。

下一章讲表单校验—同步校验、异步校验、用 Zod 做 Schema 校验,把表单的防御能力拉满。