本指南讲如何用 TanStack Form 搭表单——但不直接装它,
而是装 @gedatou/cadenza-form:一层门面,全量转发 TanStack Form 的 API,另外把
表单惯例做成默认——aria 接线、错误展示时机、提交处理,都不再需要手写胶水。
控件与布局来自本库的 Field 家族。
使用
pnpm add @gedatou/cadenza-form包内全量转发 @tanstack/react-form,只装这一个,所有 API 从同一入口 import:
import {
fieldControlProps,
fieldErrors,
fieldInvalidState,
formProps,
useForm,
} from '@gedatou/cadenza-form'<form {...formProps(form)}>
<form.Field name="title">
{field => (
<Field>
<FieldLabel htmlFor={field.name}>标题</FieldLabel>
<Input
{...fieldControlProps(field)}
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
/>
<FieldError id={fieldInvalidState(field).errorId} errors={fieldErrors(field)} />
</Field>
)}
</form.Field>
</form>formProps(form) 一次展开 noValidate + onSubmit(提交管线)。noValidate 是
统一给的:schema 是唯一校验真源,而浏览器的原生约束校验(type="email"、
required、pattern)会在 submit 事件派发之前拦截提交并弹原生气泡——
门面的整条提交管线(计数、补校验、错误揭示、聚焦)都不会运行。手写
onSubmit={formSubmitHandler(form)} 时务必自己补上 noValidate。
思路
表单状态归 TanStack Form,标记归 Field 家族,两者之间的 胶水归门面:
useForm管状态,form.Field的 render prop 模式做受控绑定——与官方用法一字不差。fieldControlProps(field)一次展开id/name/aria-describedby/aria-invalid/aria-required五条线;手写版本要在Field、控件、FieldError三处各接一遍。fieldErrors(field)带展示门禁:onChange 实时校验,错误信息等字段被改过且失焦过 (或表单提交过)才出现(见显示错误)。formProps(form)一次展开noValidate与提交处理(底层是formSubmitHandler):preventDefault、失败后补跑完整校验、把错误字段标记为已 blur(让门禁放行展示)、 再把焦点送到第一个无效控件。- 校验用 zod(或任何 Standard Schema 实现),直接塞进
validators,零适配层。
解剖
一个字段的完整接线如下——fieldControlProps 展开的四条 aria 线、fieldErrors
的门禁、data-invalid 的染色,就是母版教程里那三处手工接线的归宿:
<form {...formProps(form)}>
<FieldGroup>
<form.Field name="title">
{(field) => {
const { errorId, invalid } = fieldInvalidState(field)
return (
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor={field.name}>标题</FieldLabel>
<Input
{...fieldControlProps(field)}
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
/>
<FieldDescription>一句话概括问题。</FieldDescription>
<FieldError id={errorId} errors={fieldErrors(field)} />
</Field>
)
}}
</form.Field>
</FieldGroup>
<Button type="submit">提交</Button>
</form>表单
从零搭出页顶那个 bug 报告表单,一共四步。
定义 schema
先用 zod 定出表单的形状。zod 4 原生实现 Standard Schema,schema 直接进
validators,不需要任何 adapter:
import { z } from 'zod'
const schema = z.object({
title: z.string().min(5, '标题至少 5 个字').max(32, '标题最多 32 个字'),
description: z.string().min(20, '描述至少 20 个字').max(100, '描述最多 100 个字'),
})配置表单
useForm 从门面 import,选项与官方完全一致。本站惯例:schema 挂在
validators.onChange 上实时校验——错误什么时候「显示」由门禁另管,不靠推迟校验:
import { useForm } from '@gedatou/cadenza-form'
import { toast } from 'sonner'
const form = useForm({
defaultValues: { title: '', description: '' },
validators: { onChange: schema },
onSubmit: async ({ value }) => {
toast.success(`已收到「${value.title}」`)
},
})组装表单
用 form.Field + Field 家族组装。完整源码:
完成
就这些。提交时 formSubmitHandler 会 preventDefault 并调用 form.handleSubmit();
校验通过则 onSubmit 拿到类型完整的表单值,不通过则所有错误字段被标记为已 blur、
错误显示在各字段旁,焦点自动移到第一个 aria-invalid 的控件上。
校验
客户端校验
schema 在每次输入时运行(validators.onChange),canSubmit / isValid 始终反映
真实校验状态。错误的「显示」被门禁延后,但校验本身从不延后。
校验模式
TanStack Form 的 validators 支持多个通道,经门面原样可用:
const form = useForm({
defaultValues: { title: '' },
validators: {
onChange: schema,
onBlur: schema,
onSubmit: schema,
},
})本站默认只挂 onChange 一个通道:实时校验保证 canSubmit 可信,展示时机交给
门禁。官方另有 revalidateLogic 走「推迟校验本身」的路线,两者语义不同——那条
路线下提交前 isValid 恒为 true。
显示错误
错误一律经 fieldErrors(field) 读取,它内置展示门禁 fieldShouldShowError——
以下两个条件满足其一才把错误交给你渲染,否则返回空数组:
dirty 条件的用意:整表 schema 校验会给没碰过的字段也写入错误,而任何点击按钮 (提交、添加一行)都会让当前字段失焦——若只看失焦,用户还没输入就会挨骂。 只有用户真动过的字段,失焦才揭示错误;没动过的字段统一等到提交时揭示。
提交过之后表单进入实时纠错模式:一切已知错误实时可见,包括之后新增的字段
(数组加行)——formSubmitHandler(form) 会在新字段登记时补跑一次校验,让它的
错误立即出现,而不是等下一次无关输入来"唤醒"。
围绕它的一族取值函数,全部走同一道门:
fieldErrors(field)——规整后的{ message }数组,直接喂给FieldError的errorsprop;没有可显示的错误时FieldError渲染为空。fieldControlProps(field)——展开id/name/aria-describedby/aria-invalid/aria-required;aria-invalid同样受门禁控制,控件的红框与 错误文字同步出现。fieldInvalidState(field)——取{ errorId, invalid },给FieldError的id与Field的data-invalid用。
绕过门禁的路径依然存在:直接读 field.state.meta.errors(官方原生 API)就没有
任何延迟。门禁是惯例约束,靠「错误只经 fieldErrors 读」这条纪律成立。
必填标记
「必填」在本站惯例里是行为性的:空值过不了校验的字段就是必填——不是 zod 的
optional() 结构(必填的 z.string().min(1) 与可留空的 z.string().max(100)
在结构上都"非 optional",TanStack Form 本身也没有必填概念)。
联动没有专属 API——信号是 Web 标准属性 aria-required:fieldControlProps
从表单自带的 schema 与默认值推导(每表单探针一次),必填时带出 aria-required;
Field 家族的 CSS 对它响应,红星是纯样式投影(content: "*" / "",读屏静默):
<FieldLabel htmlFor={field.name}>标题</FieldLabel>
<Input {...fieldControlProps(field)} … /> {/* 必填时自动 aria-required,红星随之点亮 */}不走展开的控件,把 aria-required 手写在它的合法承载者上(与你本就手写
aria-invalid 的位置相同):Select 的 trigger、RadioGroup 根、Slider 根、
Checkbox / Switch 本体。组标题(FieldLegend)由「组控件是 FieldSet 直接
子级且带 aria-required」点亮——RadioGroup 场景自动成立。
跨字段必填(确认密码)推导测不出:把 aria-required 手写在控件上(语义本来
就该在那里),红星随 CSS 自动跟上。默认值本身合法的字段(默认开启的开关)
不会被标——用户不可能失败它。
两处 ARIA 真空角用标签部件的 required prop 兜底(prop 星在场时 CSS 星
自动让位,不会双开):
- Checkbox 多选组的组标题——
grouprole 不合法承载aria-required,逐项 设置又是语义谎言; - InfiniteCombobox 的 label——触发器是
buttonrole,同样不承载。
不经表单的场景仍可用底层的 requiredFields(schema, emptyValues) 自己算,或
直接用标签的 required prop。复杂表单整表接的就是这套:简介
(可留空)与提醒开关没有星号,其余全部由 aria-required 点亮,仅两处真空角
走 prop。
不同控件类型
同一套接线适配 Field 家族的全部控件;差异只在受控 props 与 id 的落点,
详见 Field 家族的标签通道说明。
Input
value / onChange / onBlur 三线手写,其余交给 fieldControlProps 展开。
Textarea
与 Input 完全同构——换控件不换写法。
NumberField
值原生就是 number | null——受控走 value / onValueChange,不需要字符串转换。
- 清空输入回
null,schema 用.nullable().refine(v => v !== null, '必填文案')表达必填。 id/name写在根上即可(Base UI 会把id路由给真输入框,htmlFor直连);aria-invalid/aria-describedby/onBlur组合到NumberFieldInput上, 组框的 invalid 环随之点亮。
Select
受控走 value / onValueChange,id 落在 SelectTrigger 上。
- 清除选择时
onValueChange回null,统一折回''再交给field.handleChange。 fieldControlProps的整体展开不适用(name归Select根、id归 trigger), 改用fieldInvalidState手工分发aria-invalid与aria-describedby。
Cascader
与 Select 同型,只是值是整条路径:value / onValueChange 收发
string[],空值是 null,id 落在 CascaderTrigger 上。
- 表单里直接存
string[] | null,zod 写z.array(z.string()).nullable().refine(path => path !== null, '…')—— 清除 ✕ 回的null原样入表,不折字符串。 name归Cascader根:原生提交时每个路径段序列化一个同名隐藏 input。
DatePicker
与 NumberField 同型:值原生是 Date | null,受控走 value / onValueChange,
不需要字符串解析——键入非法文本不落值,表单永远只见合法 Date 或 null。
- 清除 ✕ 或清空文本回
null,schema 用z.date().nullable().refine(value => value !== null, '必填文案')表达必填。 id/name写在根上(id转发给输入框,htmlFor直连);aria-invalid/aria-describedby/onBlur组合到DatePickerInput上。name归根:原生提交时隐藏 input 序列化yyyy-MM-dd——TanStack 表单存的 是Date对象,两条通道互不干扰。- 点开日历会把焦点短暂移进弹层,blur 门禁因此提前打开——错误展示时机 不受影响(依旧要 dirty 才亮)。
DateRangePicker
同一套接线的双端版:值是 { from?, to? } | null,两端都可选——先点哪个输入框
就先填哪一端,所以只有 from 或只有 to 的半程都会入表;完整性交给 refine
把关。
- zod 写
z.object({ from: z.date().optional(), to: z.date().optional() }).nullable().refine(v => v !== null && v.from !== undefined && v.to !== undefined, '…'), 空值与半程共用一句文案。两端都要.optional()——漏一个,那种半程会撞上 zod 自己的必填报错,绕过你写的文案。 id落在起点输入框(根的id转发给它);两个输入框各自接aria-invalid/aria-describedby/onBlur。name归根:原生提交时两个同名隐藏 input(起、止各一),FormData.getAll一次拿到两端。
Checkbox
勾选列表是 mode="array" 的数组字段,pushValue / removeValue 是 TanStack Form
原生 API,经门面原样可用。
- 单个布尔勾选(如「同意条款」)不需要
mode="array",直接checked={field.state.value}+onCheckedChange,见复杂表单。 - 错误挂在
FieldSet级,各项只接aria-invalid/aria-describedby。
Radio Group
受控在组上(value / onValueChange),aria-invalid 在各项上。
- 组本身接不到
htmlFor:给FieldLegend一个id,在RadioGroup上用aria-labelledby指回去。
Switch
与 Checkbox 同一契约:checked / onCheckedChange。
InputOTP
验证码输入也是表单控件——协议是 React DOM 的:onChange 直接收字符串,
可以把 field.handleChange 原样递过去。
id落在横跨所有格子的隐形真 input 上,fieldControlProps整体展开照常成立。- 校验用
z.string().regex(/^\d{6}$/, '…')这类长度+字符约束。
InfiniteSelect / InfiniteCombobox
异步分页选择同样是表单控件,绑定有三个专属要点。
- 表单持久化 id,不是对象:单选的受控
value是string | null,而onValueChange回传的是对象——表单里存 id(未加载页的预选项只有 id 没有对象), 展示文案用局部 state 存对象。 triggerId是FieldLabel htmlFor的落点;name会在触发器旁(弹层外) 渲染隐藏 input,弹层关闭不影响序列化。- schema 用
z.string().nullable().refine(v => v !== null, '…')表达必选。
复杂表单
控件全家 × zod 常见形态的综合示例——一套门面接线贯穿所有绑定。
覆盖的 zod 形态,可按需取用:
- 必填文本:
z.string().min(n, '…')(姓名、密码) - 邮箱:
z.email('…'),配 InputGroup 图标前缀 - 数字控件:NumberField 的值原生是
number | null,z.number().int().min().max().nullable().refine(v => v !== null, '必填文案'), 无需字符串转换(年龄) - 字符串转数字:用普通文本框收数字时走
z.string().transform(Number).pipe(z.number({ error: '…' }).int().min().max()), NaN 由z.number兜住。注意 validators 只校验不转换——转换产物在提交时用schema.parse(value)取 - 跨字段校验:
.superRefine()+path: ['confirmPassword'],错误落在指定字段上(确认密码) - 可选限长:
z.string().max(n),允许留空(简介) - 单选必选:
z.string().min(1, '请选择…')(Select 声部、RadioGroup 经验) - 多选下限:
z.array(z.string()).min(1, '…'),mode="array"+ Checkbox 列表(排练时段) - 数字范围:
z.number().min(n)配 Slider(每周时长) - 必须勾选:
z.boolean().refine(Boolean, '…')(同意守则) - 格式约束:
z.string().regex(/^\d{6}$/, '…')配 InputOTP(短信验证码) - 异步选择存 id:
z.string().nullable().refine(v => v !== null, '…')配 InfiniteCombobox——表单持久化 id,展示对象走局部 state(最喜欢的作曲家) - 无校验字段:
z.boolean()照常参与提交(排练提醒 Switch)
createFormHook
字段外壳(Field + aria + 错误)在每个表单里长得都一样——用 createFormHook
把它沉淀成组件,注册一次,处处 <field.TextField>:
import { createFormHook, useFieldContext } from '@gedatou/cadenza-form'
function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
const { errorId, invalid } = fieldInvalidState(field)
return (
<Field data-invalid={invalid || undefined}>
<FieldLabel htmlFor={field.name}>{label}</FieldLabel>
<Input
{...fieldControlProps(field)}
value={field.state.value}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
/>
<FieldError id={errorId} errors={fieldErrors(field)} />
</Field>
)
}
const { useAppForm } = createFormHook({ fieldComponents: { TextField } })与官方 createFormHook 的唯一差别:contexts 由包内单例自动注入,
fieldComponents / formComponents 都可省略。useFieldContext /
useFormContext 也由包导出,供字段组件取值。需要自定义 contexts 的场景直接用
@tanstack/react-form。
重置表单
form.reset() 回到默认值。异步载入的默认值(编辑表单先渲染空表、数据到了再回填)
用 useFormReset 吸掉样板——defaultValues 引用变化时整表回填:
import { useFormReset } from '@gedatou/cadenza-form'
const form = useForm({ defaultValues, onSubmit })
useFormReset(form, defaultValues)<Button type="button" variant="outline" onClick={() => form.reset()}>
重置
</Button>提交状态
useFormSubmitting(form) 订阅 isSubmitting,直接喂给按钮的 pending:
import { useFormSubmitting } from '@gedatou/cadenza-form'
const submitting = useFormSubmitting(form)
return <Button type="submit" pending={submitting}>提交</Button>数组字段
mode="array" 的数组字段管理(添加、删除、逐项校验)是 TanStack Form 的原生
能力,经门面原样可用。
数组结构
父字段开 mode="array",field.state.value 是数组本体:
<form.Field name="emails" mode="array">
{field => (
<FieldGroup>
{field.state.value.map((_, index) => (
// 每个条目一个嵌套子字段
))}
</FieldGroup>
)}
</form.Field>嵌套字段
条目内的属性用括号寻址:emails[${index}].address。子字段照常走
fieldControlProps / fieldErrors:
<form.Field name={`emails[${index}].address`}>
{subField => (
<InputGroupInput
{...fieldControlProps(subField)}
value={subField.state.value}
onBlur={subField.handleBlur}
onChange={event => subField.handleChange(event.target.value)}
/>
)}
</form.Field>添加条目
field.pushValue(item),到上限就禁用按钮:
<Button
type="button"
variant="outline"
disabled={field.state.value.length >= 5}
onClick={() => void field.pushValue({ address: '' })}
>
添加邮箱
</Button>删除条目
field.removeValue(index),只剩一条时收起删除键:
{field.state.value.length > 1 && (
<InputGroupButton
aria-label={`删除第 ${index + 1} 个邮箱`}
onClick={() => void field.removeValue(index)}
>
<IconX aria-hidden />
</InputGroupButton>
)}数组校验
数组本身与条目分别校验,都写在同一个 schema 里:
const schema = z.object({
emails: z
.array(z.object({ address: z.email('请输入有效的邮箱地址') }))
.min(1, '至少填一个邮箱')
.max(5, '最多 5 个邮箱'),
})分步表单
复杂表单的分步版:一个 useForm 贯穿五步,Stepper
受控展示进度——值住在 form store 里,步骤切换只是卸载字段组件,输入与校验状态都
不丢。每一步的推进都要过一段 200–500ms 的异步确认,期间当前步的指示器转 Spinner:
分步的全部要点在「前进」这一下:
- 「下一步」是本步的提交尝试,不是表单提交。 真提交会让
submissionAttempts > 0全表开闸(见显示错误的门禁第二条)——后面步骤的字段一渲染就带着 错误挨骂。所以中间步不碰handleSubmit。 - 先补跑校验,再看本步。 没动过的字段此前没有任何校验记录,
form.validate('change')强制分发一遍;然后只检查本步字段的getFieldMeta(name).errors——其余字段的错误 照常被门禁挡住,不受影响。跨字段校验照常工作:确认密码的superRefine错误落在 第一步自己的字段上。 - 有错就手动开闸: 对本步的错误字段
setFieldMeta标记isDirty+isBlurred(门禁第一条),错误与红框只在这些字段上出现,再用focusFirstInvalidControl把焦点送过去。语义上这就是把「试图前进」当作该步的提交尝试。 - 异步推进用一个
advancing状态锁整面。 本地校验过了才发异步确认;期间 「下一步」pending、Stepperloading(当前步转 Spinner)、trigger 与「上一步」 一并拦下。pending不是装饰而是防误交:「下一步」与「提交」渲染在同一个位置 (React 还会复用同一个 button 节点),推进完成的瞬间,快速双击的第二击就落在 「提交」上——没有pending拦着,表单就被误交了。 - 回头随便走,前进必须过关。 Stepper 受控在外部
step上;onValueChange里 对next > step与advancing期间的点击调eventDetails.cancel(),点已完成的 步骤则直接放行。 - 真正的提交只在最后一步,
formProps(form)照常接管:失败揭示 + 聚焦 + 提交 管线全部是包默认。
API
@gedatou/cadenza-form 的完整导出。官方 API 全量转发,下表只列门面新增的部分: