首页 / TanStack 生态入门教程 / 表单校验

TanStack 生态入门教程

表单校验

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

TanStackTanStack 生态入门教程TanStack Form表单校验ZodValibot异步校验Schema 校验

33. 表单校验

本节目标:掌握 TanStack Form 的完整校验体系。学会同步校验、异步校验、字段级与表单级校验、用 Zod/Valibot 做 Schema 校验、动态校验和自定义错误信息。学完你能应对各种表单校验场景。

33.1 校验的三种时机

表单校验在三个时机触发:

  • onChange — 值变化时校验,实时反馈。
  • onBlur — 失焦时校验,等用户输完再检查。
  • onSubmit — 提交时校验,最后一道防线。

打个比方:onChange 像你边写代码边看报错提示,onBlur 像你写完一行按了保存才检查,onSubmit 像你提交代码评审时才被告知哪里有问题。

TanStack Form 支持在每个时机单独设校验规则:

<form.Field
  name="age"
  validators={{
    onChange: ({ value }) => value < 0 ? '年龄不能为负' : undefined,
    onBlur: ({ value }) => value < 18 ? '必须年满 18 岁' : undefined,
    onSubmit: ({ value }) => value > 150 ? '年龄不真实' : undefined,
  }}
>
  {(field) => (
    <>
      <input
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(Number(e.target.value))}
      />
      {field.state.meta.errors.length > 0 && (
        <span className="text-red-500">{field.state.meta.errors[0]}</span>
      )}
    </>
  )}
</form.Field>

校验函数返回 undefined 表示通过,返回字符串表示有错误。

Note

三个时机不是非选其一,可以同时设。它们各自在对应时机触发,错误信息会汇总到 field.state.meta.errors 里。errorMap 能按时机区分错误来源。

33.2 同步校验

同步校验是最常见的—检查值是否符合规则,立刻返回结果。

33.2.1 字段级同步校验

<form.Field
  name="username"
  validators={{
    onChange: ({ value }) => {
      if (!value) return '用户名不能为空'
      if (value.length < 3) return '用户名至少 3 个字符'
      if (value.length > 20) return '用户名最多 20 个字符'
      if (!/^[a-zA-Z0-9_]+$/.test(value)) return '只能包含字母、数字和下划线'
      return undefined // 校验通过
    },
  }}
>
  {(field) => (
    <div>
      <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>

校验函数接收一个对象,里面有 value(当前值)和 field(字段实例),返回 undefined 或错误字符串。

33.2.2 表单级同步校验

有些校验不是针对单个字段,而是跨字段联动。比如「密码和确认密码必须一致」:

const form = useForm({
  defaultValues: {
    password: '',
    confirmPassword: '',
  },
  validators: {
    // 表单级校验:在 onSubmit 时检查密码匹配
    onSubmit: ({ value }) => {
      if (value.password !== value.confirmPassword) {
        return '两次输入的密码不一致'
      }
      return undefined
    },
  },
  onSubmit: async ({ value }) => {
    console.log('提交', value)
  },
})

表单级校验在 useFormvalidators 里设,value 是整个表单的值。返回的错误会出现在 form.state.meta.errors 里。

Tip

跨字段校验放表单级,单字段校验放字段级。密码匹配、日期范围(结束日期 > 开始日期)这类放表单级最合适。

33.3 异步校验

有些校验需要请求服务端,比如检查用户名是否已被注册。这种用异步校验:

<form.Field
  name="username"
  validators={{
    // 异步校验函数
    onChangeAsync: async ({ value }) => {
      // 模拟请求服务端检查用户名
      await new Promise((resolve) => setTimeout(resolve, 1000))

      const taken = ['admin', 'test', 'user'] // 已被占用的用户名
      if (taken.includes(value)) {
        return '该用户名已被注册'
      }
      return undefined
    },
  }}
>
  {(field) => (
    <div>
      <input
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
      />
      {field.state.meta.errors.length > 0 && (
        <span className="text-red-500 text-sm">
          {field.state.meta.errors[0]}
        </span>
      )}
    </div>
  )}
</form.Field>

异步校验函数名加 Async 后缀:onChangeAsynconBlurAsynconSubmitAsync

33.3.1 防抖

异步校验每次输入都发请求太浪费。加个防抖(debounce),用户停下来才发:

<form.Field
  name="username"
  validators={{
    onChangeAsyncDebounceMs: 500, // 停止输入 500ms 后才执行校验
    onChangeAsync: async ({ value }) => {
      const res = await fetch(`/api/check-username?name=${value}`)
      const data = await res.json()
      return data.taken ? '用户名已被注册' : undefined
    },
  }}
>
  {(field) => (
    /* ... */
  )}
</form.Field>

onChangeAsyncDebounceMs: 500 表示用户停止输入 500 毫秒后才触发异步校验。在此期间继续输入会取消上一次的校验。这比自己在 setTimeout 里写防抖方便多了。

Warning

异步校验期间,用户可能已经继续输入了。TanStack Form 会自动取消过期的异步校验(基于最新的值),不会出现旧结果覆盖新结果的问题。但如果你在异步函数里用了 AbortController,记得在取消时处理。

33.4 Schema 校验(Zod)

手写校验函数适合简单场景。字段多了,手写校验又长又重复。Schema 校验用专门的校验库定义规则,更简洁更可维护。

33.4.1 安装 Zod

TanStack Form v1 原生支持 Standard Schema 规范,Zod、Valibot 等 schema 库可以直接传入,不需要额外的适配器包:

npm install zod

33.4.2 定义 Schema

用 Zod 定义表单数据的校验规则:

import { z } from 'zod'

const formSchema = z.object({
  username: z
    .string()
    .min(3, '用户名至少 3 个字符')
    .max(20, '用户名最多 20 个字符')
    .regex(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字和下划线'),
  email: z.string().email('请输入有效的邮箱地址'),
  age: z.number().min(18, '必须年满 18 岁').max(150, '年龄不真实'),
})

Zod 的链式 API 很直观:z.string() 定义字符串类型,.min()/.max() 加约束,第二个参数是错误信息。

33.4.3 在表单中使用

把 Schema 直接传给 useFormvalidators,不用任何包装函数:

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

const form = useForm({
  defaultValues: {
    username: '',
    email: '',
    age: 0,
  },
  validators: {
    onChange: formSchema, // 直接传 Zod schema
  },
  onSubmit: async ({ value }) => {
    console.log('提交', value)
  },
})

Schema 校验的好处:

  • 一处定义,处处使用:一个 Schema 对象校验整个表单,不用每个字段单独写。
  • 类型自动推导:Zod Schema 能推导出 TypeScript 类型,和 defaultValues 的类型一致。
  • 错误信息统一:错误信息在 Schema 里定义,格式统一。
Note

用 Standard Schema 校验时,field.state.meta.errors 里的元素是对象(有 message 属性),不是字符串。展示错误时用 errors[0]?.message 取信息。手写校验返回字符串时,errors[0] 直接就是字符串。两种方式别搞混。

Tip

Schema 校验也可以放在字段级。<Field validators={{ onChange: z.string().email('请输入有效邮箱') }}> 只校验单个字段。表单级校验整个对象,字段级校验单个值。

33.5 Schema 校验(Valibot)

Valibot 是比 Zod 更轻量的替代品,API 类似但打包体积小很多。同样原生支持 Standard Schema,直接传入即可:

npm install valibot
import { object, string, number, minLength, email, minValue } from 'valibot'

const formSchema = object({
  username: string([minLength(3, '用户名至少 3 个字符')]),
  email: string([email('请输入有效的邮箱地址')]),
  age: number([minValue(18, '必须年满 18 岁')]),
})

const form = useForm({
  defaultValues: { username: '', email: '', age: 0 },
  validators: {
    onChange: formSchema, // 直接传 Valibot schema
  },
  onSubmit: async ({ value }) => console.log(value),
})
Note

Zod 和 Valibot 功能类似,选哪个看团队偏好。Zod 生态更广、用的人多;Valibot 更轻量、Tree-shaking 友好。TanStack Form 两个都支持。

33.6 动态校验

校验规则需要根据其他字段的值变化,叫动态校验(Dynamic Validation)

比如:选了「企业用户」类型时,公司名字段必填;选了「个人用户」时不必填。

const form = useForm({
  defaultValues: {
    userType: 'personal' as 'personal' | 'enterprise',
    companyName: '',
  },
  onSubmit: async ({ value }) => console.log(value),
})

// 公司名字段:根据 userType 动态校验
<form.Field
  name="companyName"
  validators={{
    onChange: ({ value, fieldApi }) => {
      const userType = fieldApi.form.getFieldValue('userType')
      if (userType === 'enterprise' && !value) {
        return '企业用户必须填写公司名'
      }
      return undefined
    },
  }}
>
  {(field) => (
    <div>
      <input
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        disabled={form.getFieldValue('userType') !== 'enterprise'}
      />
      {field.state.meta.errors.length > 0 && (
        <span className="text-red-500 text-sm">{field.state.meta.errors[0]}</span>
      )}
    </div>
  )}
</form.Field>

关键点:校验函数里通过 fieldApi.form.getFieldValue('userType') 读取其他字段的值,根据它决定校验规则。

Warning

动态校验有性能考量:每次 companyName 变化都读 userType 的值。如果依赖的字段很多,考虑用表单级校验统一处理。

33.7 自定义错误信息

校验函数返回的字符串就是错误信息。想返回更结构化的错误,可以返回对象:

<form.Field
  name="password"
  validators={{
    onChange: ({ value }) => {
      const errors = []
      if (value.length < 8) errors.push('至少 8 个字符')
      if (!/[A-Z]/.test(value)) errors.push('需要大写字母')
      if (!/[0-9]/.test(value)) errors.push('需要数字')

      if (errors.length > 0) {
        return errors.join(';') // "至少 8 个字符;需要大写字母;需要数字"
      }
      return undefined
    },
  }}
>
  {(field) => (
    <div>
      <input type="password" ... />
      {field.state.meta.isTouched && field.state.meta.errors.length > 0 && (
        <ul className="text-red-500 text-sm">
          {field.state.meta.errors.map((err, i) => (
            <li key={i}>{err}</li>
          ))}
        </ul>
      )}
    </div>
  )}
</form.Field>

33.8 完整校验示例

把同步、异步、Schema 校验组合起来:

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

const registerSchema = z.object({
  username: z.string().min(3, '至少 3 个字符').max(20, '最多 20 个字符'),
  email: z.string().email('邮箱格式不正确'),
  age: z.number().min(18, '必须年满 18 岁'),
})

function RegisterForm() {
  const form = useForm({
    defaultValues: {
      username: '',
      email: '',
      age: 0,
    },
    validators: {
      onChange: registerSchema, // 直接传 Zod schema
    },
    onSubmit: async ({ value }) => {
      console.log('提交:', value)
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
      className="max-w-md space-y-4"
    >
      {/* 用户名:Schema 校验 + 异步检查是否被占用 */}
      <form.Field
        name="username"
        validators={{
          onChangeAsyncDebounceMs: 500,
          onChangeAsync: async ({ value }) => {
            await new Promise((r) => setTimeout(r, 300))
            const taken = ['admin', 'root']
            return taken.includes(value) ? '用户名已被占用' : undefined
          },
        }}
      >
        {(field) => (
          <div>
            <label className="block text-sm 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"
            />
            {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>

      {/* 邮箱:Schema 校验 */}
      <form.Field name="email">
        {(field) => (
          <div>
            <label className="block text-sm 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"
            />
            {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>

      {/* 年龄:Schema 校验 */}
      <form.Field name="age">
        {(field) => (
          <div>
            <label className="block text-sm 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"
            />
            {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>

      <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
        {([canSubmit, isSubmitting]) => (
          <button
            type="submit"
            disabled={!canSubmit || isSubmitting}
            className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
          >
            {isSubmitting ? '提交中...' : '注册'}
          </button>
        )}
      </form.Subscribe>
    </form>
  )
}

这个表单同时用了 Schema 校验(Zod)和异步校验(检查用户名占用),还有防抖和错误展示。

33.9 常见坑

坑一:同步校验和异步校验混用错误不显示。 同步校验的错误在 errors 里,异步校验的错误也在 errors 里。但异步校验完成前 errors 是空的。如果你的错误展示逻辑依赖同步状态,可能异步错误显示不出来。确保错误展示逻辑检查的是最新的 field.state.meta.errors

坑二:Schema 校验和手写校验冲突。 同时在表单级用 Schema 校验、字段级又手写校验,两个都返回错误,errors 里会有两条。确定用一种方式为主,另一种补充。

坑三:异步校验没加防抖。 不加 onChangeAsyncDebounceMs,每输一个字符就发请求,服务端被刷爆,用户体验也差(频繁显示/隐藏错误)。

坑四:Schema 类型和 defaultValues 类型不匹配。 Zod 里 age: z.number()defaultValuesage: ''(字符串),类型推导冲突。两边类型要一致。

坑五:动态校验读不到最新值。 在校验函数里用闭包变量读其他字段值,可能拿到旧值。用 fieldApi.form.getFieldValue() 保证读到最新值。

33.10 小结

这一章讲了 TanStack Form 的完整校验体系:

  • 三种时机onChange(实时)、onBlur(失焦)、onSubmit(提交),可同时使用。
  • 同步校验:字段级返回 undefined 或错误字符串;表单级做跨字段校验。
  • 异步校验onChangeAsync 等异步函数,配 onChangeAsyncDebounceMs 防抖。
  • Schema 校验:用 Zod 或 Valibot 定义校验规则,v1 原生支持 Standard Schema,直接传入不用适配器。
  • 动态校验:校验函数里通过 fieldApi.form.getFieldValue() 读其他字段值,动态调整规则。

下一章讲表单组合、数组字段、联动字段、监听器等进阶用法。