TanStack Form 表单验证完全指南:字段级与表单级的同步、异步及 Schema 验证
【免费下载链接】form🤖 Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, and Lit.项目地址: https://gitcode.com/GitHub_Trending/form/form
验证是 TanStack Form 的核心能力。无论你使用 React、Vue、Angular、Solid 还是 Preact,这套 headless 表单状态管理库都提供了一致的验证机制:你可以自由控制验证时机(输入、失焦、提交、挂载)、在字段级或表单级定义规则、使用同步函数、异步请求甚至 Standard Schema 生态(Zod、Valibot、ArkType、Effect/Schema)来完成类型安全的验证。读完本文,你将掌握 TanStack Form 中验证的完整用法,包括错误展示、异步防抖、表单级向字段级回写错误,以及如何阻止无效表单提交。
验证何时执行?由你自己决定
TanStack Form 的验证时机完全可配置。<Field />组件接受一系列回调属性,如onChange、onBlur、onSubmit等。这些回调会收到字段的当前值(value)以及fieldApi对象。如果发现验证错误,只需返回错误信息字符串,它会自动出现在field.state.meta.errors中。
下面的例子在每次按键时(onChange)执行验证:
<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>如果希望改为在字段失焦时验证,只需把验证器从onChange换到onBlur,并记得在<input>上绑定field.handleBlur(同时仍需保留onChange让 TanStack Form 收到输入变化):
<form.Field name="age" validators={{ onBlur: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>你甚至可以针对同一个字段,在不同时机做不同的验证。例如每次按键检查"是否满 13 岁",失焦时检查"是否为负数":
<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, onBlur: ({ value }) => (value < 0 ? 'Invalid value' : undefined), }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>由于field.state.meta.errors是数组,某一时刻触发的所有相关错误都会被展示。如果你想知道错误是"什么时候"产生的,可以使用field.state.meta.errorMap,它按验证时机(onChange、onBlur等)分键保存错误。
从实现上看,这些验证时机由 ValidationLogic.ts 中的defaultValidationLogic统一调度:当事件类型为change时运行onChange/onChangeAsync并附带清理服务端错误;blur时运行onBlur/onBlurAsync;submit时则会依次运行 change、blur、submit 与服务端相关验证器,确保提交前所有时机都被覆盖。
展示错误:errors 数组与 errorMap
配置好验证后,把错误数组映射到 UI 上即可:
<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => { return ( <> {/* ... */} {!field.state.meta.isValid && ( <em>{field.state.meta.errors.join(',')}</em> )} </> ) }} </form.Field>也可以使用errorMap精准读取某一时机产生的错误:
<form.Field name="age" validators={{ onChange: ({ value }) => value < 13 ? 'You must be 13 to make an account' : undefined, }} > {(field) => ( <> {/* ... */} {field.state.meta.errorMap['onChange'] ? ( <em>{field.state.meta.errorMap['onChange']}</em> ) : null} </> )} </form.Field>值得强调的是,errors数组和errorMap的类型与验证器返回的类型完全一致。因此验证器可以返回任意结构化对象,而不只是字符串:
<form.Field name="age" validators={{ onChange: ({ value }) => (value < 13 ? { isOldEnough: false } : undefined), }} > {(field) => ( <> {/* ... */} {/* errorMap.onChange 的类型是 `{isOldEnough: false} | undefined` */} {/* meta.errors 的类型是 `Array<{isOldEnough: false} | undefined>` */} {!field.state.meta.errorMap['onChange']?.isOldEnough ? ( <em>The user is not old enough</em> ) : null} </> )} </form.Field>在 types.ts 中可以确认errorMap的定义:ValidationErrorMap以onMount、onChange、onBlur、onSubmit、onDynamic、onServer为键(onXxxAsync的结果会合并进对应同步键中),而errors数组则是由meta.errors派生的扁平化集合。此外,字段元数据还提供isValidating(是否有异步验证进行中)与isValid(是否存在错误)等派生状态,相关类型见 FieldLikeMetaDerived。真实项目中的FieldInfo组件展示了典型用法,参见 examples/react/standard-schema/src/index.tsx。
字段级验证 vs 表单级验证
前面每个<Field>通过onChange、onBlur等回调定义了自己的验证规则。同样地,你也可以通过useForm()传入类似的回调,在表单级定义验证规则:
export default function App() { const form = useForm({ defaultValues: { age: 0, }, onSubmit: async ({ value }) => { console.log(value) }, validators: { // 像给字段添加验证器一样,给整个表单添加验证器 onChange({ value }) { if (value.age < 13) { return 'Must be 13 or older to sign' } return undefined }, }, }) // 订阅表单的 `errorMap`,让它的更新触发重新渲染 // 也可以使用 `form.Subscribe` const formErrorMap = useSelector(form.store, (state) => state.errorMap) return ( <div> {/* ... */} {formErrorMap.onChange ? ( <div> <em>There was an error on the form: {formErrorMap.onChange}</em> </div> ) : null} {/* ... */} </div> ) }注意:上面使用的是返回string的函数验证器。当使用 Standard Schema 验证器(Zod、Valibot、ArkType、Effect/Schema)时,state.errorMap.onChange的类型变为Record<string, StandardSchemaV1Issue[]>,按字段名作为键。需要遍历该 Record 来渲染消息:
{ formErrorMap.onChange ? ( <div> <em> There was an error on the form:{' '} {Object.values(formErrorMap.onChange) .flat() .map((issue) => issue.message) .join(', ')} </em> </div> ) : null }表单级 errorMap 的完整类型定义(FormValidationErrorMap)与GlobalFormValidationError结构可以在 types.ts 中找到。当验证器返回{ form, fields }结构时,form键是全局表单错误,fields键则按字段深键(DeepKeys)映射到具体字段,例如'socials[0].url'或'details.email'。
从表单验证器设置字段级错误
一个常见场景是:在表单的onSubmitAsync验证器中,通过一次 API 调用同时验证所有字段,并把错误写回各个字段:
export default function App() { const form = useForm({ defaultValues: { age: 0, socials: [], details: { email: '', }, }, validators: { onSubmitAsync: async ({ value }) => { // 在服务端验证整个 value const hasErrors = await verifyDataOnServer(value) if (hasErrors) { return { form: 'Invalid data', // `form` 键是可选的 fields: { age: 'Must be 13 or older to sign', // 用字段名设置嵌套字段的错误 'socials[0].url': 'The provided URL does not exist', 'details.email': 'An email is required', }, } } return null }, }, }) return ( <div> <form onSubmit={(e) => { e.preventDefault() e.stopPropagation() void form.handleSubmit() }} > <form.Field name="age"> {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field> <form.Subscribe selector={(state) => [state.errorMap]} children={([errorMap]) => errorMap.onSubmit ? ( <div> <em>There was an error on the form: {errorMap.onSubmit}</em> </div> ) : null } /> {/*...*/} </form> </div> ) }这个模式有完整的可运行示例:examples/react/field-errors-from-form-validators/src/index.tsx 通过Promise.all并行调用两个模拟服务端接口(年龄校验、用户名占用校验),把失败信息分别回写到age和username字段,同时用form.Subscribe展示全局错误。
需要特别提醒:如果表单级验证函数返回了某个错误,它可能被字段级验证覆盖。例如:
const form = useForm({ defaultValues: { age: 0, }, validators: { onChange: ({ value }) => { return { fields: { age: value.age < 12 ? 'Too young!' : undefined, }, } }, }, }) // ... return ( <form.Field name="age" validators={{ onChange: ({ value }) => (value % 2 === 0 ? 'Must be odd!' : undefined), }} children={() => <>{/* ... */}</>} /> )上述代码最终只会显示
'Must be odd!',即使表单级验证返回了'Too young!'。因为字段级验证在同一次事件中优先级更高并覆盖了表单级写回的错误。这也是源码中validateSync之后字段级错误 map 合并顺序所决定的(见 FieldApi.ts 中fieldsErrorMap与字段自身setErrorMap的叠加逻辑)。
异步函数验证
大多数验证是同步的,但网络请求等异步操作同样是刚需。TanStack Form 为此提供了专门的onChangeAsync、onBlurAsync等异步验证方法:
<form.Field name="age" validators={{ onChangeAsync: async ({ value }) => { await new Promise((resolve) => setTimeout(resolve, 1000)) return value < 13 ? 'You must be 13 to make an account' : undefined }, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>同步与异步验证器可以共存。例如同一个字段同时定义onBlur和onBlurAsync:
<form.Field name="age" validators={{ onBlur: ({ value }) => (value < 13 ? 'You must be at least 13' : undefined), onBlurAsync: async ({ value }) => { const currentAge = await fetchCurrentAgeOnProfile() return value < currentAge ? 'You can only increase the age' : undefined }, }} > {(field) => ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} type="number" onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.valueAsNumber)} /> {!field.state.meta.isValid && ( <em role="alert">{field.state.meta.errors.join(', ')}</em> )} </> )} </form.Field>执行顺序上,同步验证(onBlur)先运行,异步验证(onBlurAsync)仅在同步验证通过后运行。如果希望无论同步验证结果如何都强制执行异步验证,把asyncAlways设为true即可。这个"同步失败即短路"的行为在 FieldApi.ts 中体现:hasErrored && !this.options.asyncAlways时会中止(abort)挂起的异步验证并直接返回错误。
底层还会用_pendingValidationsCount计数器跟踪进行中的异步验证(见 FieldApi.ts),多个异步验证同时完成时不会出现竞态,isValidating也会随之正确翻转。
内置防抖
异步验证往往用于查询数据库,但每次按键都发网络请求无异于自毁后端。TanStack Form 内置了防抖支持,只需一个属性:
<form.Field name="age" asyncDebounceMs={500} validators={{ onChangeAsync: async ({ value }) => { // ... }, }} children={(field) => { return <>{/* ... */}</> }} />上述配置会让所有异步调用以 500ms 延迟防抖执行。你还可以按验证器单独覆盖防抖时间:
<form.Field name="age" asyncDebounceMs={500} validators={{ onChangeAsyncDebounceMs: 1500, onChangeAsync: async ({ value }) => { // ... }, onBlurAsync: async ({ value }) => { // ... }, }} children={(field) => { return <>{/* ... */}</> }} />效果是onChangeAsync每 1500ms 执行一次,而onBlurAsync仍按 500ms 执行。防抖的解析逻辑在 utils.ts 的getAsyncValidatorArray中:onChangeAsyncDebounceMs、onBlurAsyncDebounceMs、onDynamicAsyncDebounceMs优先于asyncDebounceMs(默认 0),而submit相关验证始终立即执行(debounceMs = 0)。
通过 Schema 库进行验证
函数验证灵活但略显啰嗦。TanStack Form 原生支持所有遵循Standard Schema 规范的校验库,最常用的包括:
- Zod
- Valibot
- ArkType
- Effect/Schema
使用方式与自定义函数完全一致,直接把 schema 传给validators即可。你也可以为整个表单定义一个 schema 传给表单级验证器,错误会自动分发到各字段:
const userSchema = z.object({ age: z.number().gte(13, 'You must be 13 to make an account'), }) function App() { const form = useForm({ defaultValues: { age: 0, }, validators: { onChange: userSchema, }, }) return ( <div> <form.Field name="age" children={(field) => { return <>{/* ... */}</> }} /> </div> ) }提示:请使用最新版本的 schema 库,旧版本可能尚未支持 Standard Schema 规范。
另外要注意:验证不会返回转换后的值(transformed values)。需要转换值请参考提交处理指南。
表单级与字段级的异步 schema 验证同样受支持,还能结合防抖:
<form.Field name="age" validators={{ onChange: z.number().gte(13, 'You must be 13 to make an account'), onChangeAsyncDebounceMs: 500, onChangeAsync: z.number().refine( async (value) => { const currentAge = await fetchCurrentAgeOnProfile() return value >= currentAge }, { message: 'You can only increase the age', }, ), }} children={(field) => { return <>{/* ... */}</> }} />如果需要对 Standard Schema 验证做更精细的控制,可以把 schema 与回调函数组合使用,通过fieldApi.parseValueWithSchema手动解析:
<form.Field name="age" asyncDebounceMs={500} validators={{ onChangeAsync: async ({ value, fieldApi }) => { const errors = fieldApi.parseValueWithSchema( z.number().gte(13, 'You must be 13 to make an account'), ) if (errors) return errors // 继续你的自定义验证 }, }} children={(field) => { return <>{/* ... */}</> }} />parseValueWithSchema只做解析并返回 issues,不会写入内部错误状态(见 FieldApi.ts),因此非常适合在自定义异步验证流程中按需组合 schema。
Standard Schema 的底层实现
TanStack Form 通过鸭子类型识别 Standard Schema:只要对象带有'~standard'属性即被视为标准 schema(standardSchemaValidator.ts 中的isStandardSchemaValidator)。StandardSchemaV1接口定义了version: 1、vendor、validate(value)等方法(同文件 L120-L149)。
验证结果分两种路径:字段级验证直接返回StandardSchemaV1Issue[];表单级验证则通过prefixSchemaToErrors把 issue 的path逐段拼回字段深键路径(数组用[index]、对象用.key),再组织成{ form, fields }结构(standardSchemaValidator.ts),这就是"整表单 schema 错误自动分发到字段"的原理。
一个真实的综合示例见 examples/react/standard-schema/src/index.tsx:同一个表单里定义了 Zod、Valibot、ArkType、Effect 四种 schema,注释掉其他三行即可无缝切换,验证逻辑与错误展示代码完全不用改。
阻止无效表单被提交
onChange、onBlur等回调在表单提交时也会运行,因此无效表单的提交会被自动拦截。表单状态对象提供了canSubmit标志:当任一字段无效且表单已被触摸(touched)时,canSubmit为false;表单未被触摸前即使某些字段"技术上"无效,canSubmit也保持true。
你可以通过form.Subscribe订阅canSubmit,例如据此禁用提交按钮(实践上建议用aria-disabled代替disabled,因为禁用按钮对无障碍不友好):
const form = useForm(/* ... */) return ( /* ... */ // 动态提交按钮 <form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]} children={([canSubmit, isSubmitting]) => ( <button type="submit" disabled={!canSubmit}> {isSubmitting ? '...' : 'Submit'} </button> )} /> )canSubmit与isPristine的组合同样出现在官方示例中:examples/react/field-errors-from-form-validators/src/index.tsx 与 examples/react/standard-schema/src/index.tsx 都通过selector={(state) => [state.canSubmit, state.isSubmitting]}驱动提交按钮状态。
如果要在用户产生任何交互前就禁止提交,可以把canSubmit与isPristine组合使用,例如!canSubmit || isPristine这种条件能有效阻止未修改即提交。
小结
TanStack Form 的验证体系围绕三个维度展开:时机(onChange/onBlur/onSubmit/onMount及其Async变体)、层级(字段级与表单级,且表单级可通过fields深键回写错误到任意嵌套字段)、形式(同步函数、异步函数、内置防抖、Standard Schema 生态)。底层由 ValidationLogic.ts 统一编排验证器执行顺序,由 types.ts 提供端到端的类型安全——错误类型从验证器返回值一路推导到errorMap与errors数组。配合 standardSchemaValidator.ts 的 Standard Schema 适配层,你可以在保持类型完整性的同时,用最简洁的方式构建健壮的表单验证。
【免费下载链接】form🤖 Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, and Lit.项目地址: https://gitcode.com/GitHub_Trending/form/form
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考