Cadenza
EN

TanStack Form

用 @gedatou/cadenza-form 门面 + Field 家族搭表单 —— API 与 TanStack Form 一致,接线与错误展示时机是包默认

本指南讲如何用 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"requiredpattern)会在 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 家族组装。完整源码:

完成

就这些。提交时 formSubmitHandlerpreventDefault 并调用 form.handleSubmit(); 校验通过则 onSubmit 拿到类型完整的表单值,不通过则所有错误字段被标记为已 blur、 错误显示在各字段旁,焦点自动移到第一个 aria-invalid 的控件上。

校验

客户端校验

schema 在每次输入时运行(validators.onChange),canSubmit / isValid 始终反映 真实校验状态。错误的「显示」被门禁延后,但校验本身从不延后。

校验模式

TanStack Form 的 validators 支持多个通道,经门面原样可用:

通道触发时机
onChange每次输入变化
onBlur字段失焦
onSubmit表单提交
const form = useForm({
  defaultValues: { title: '' },
  validators: {
    onChange: schema,
    onBlur: schema,
    onSubmit: schema,
  },
})

本站默认只挂 onChange 一个通道:实时校验保证 canSubmit 可信,展示时机交给 门禁。官方另有 revalidateLogic 走「推迟校验本身」的路线,两者语义不同——那条 路线下提交前 isValid 恒为 true

显示错误

错误一律经 fieldErrors(field) 读取,它内置展示门禁 fieldShouldShowError—— 以下两个条件满足其一才把错误交给你渲染,否则返回空数组:

条件来源
字段被改过失焦过meta.isDirty && meta.isBlurred(dirty 是粘性的:输入过再清空仍算改过)
表单提交过form.state.submissionAttempts > 0

dirty 条件的用意:整表 schema 校验会给没碰过的字段也写入错误,而任何点击按钮 (提交、添加一行)都会让当前字段失焦——若只看失焦,用户还没输入就会挨骂。 只有用户真动过的字段,失焦才揭示错误;没动过的字段统一等到提交时揭示。

提交过之后表单进入实时纠错模式:一切已知错误实时可见,包括之后新增的字段 (数组加行)——formSubmitHandler(form) 会在新字段登记时补跑一次校验,让它的 错误立即出现,而不是等下一次无关输入来"唤醒"。

围绕它的一族取值函数,全部走同一道门:

  • fieldErrors(field)——规整后的 { message } 数组,直接喂给 FieldErrorerrors prop;没有可显示的错误时 FieldError 渲染为空。
  • fieldControlProps(field)——展开 id / name / aria-describedby / aria-invalid / aria-requiredaria-invalid 同样受门禁控制,控件的红框与 错误文字同步出现。
  • fieldInvalidState(field)——取 { errorId, invalid },给 FieldErroridFielddata-invalid 用。

绕过门禁的路径依然存在:直接读 field.state.meta.errors(官方原生 API)就没有 任何延迟。门禁是惯例约束,靠「错误只经 fieldErrors 读」这条纪律成立。

必填标记

「必填」在本站惯例里是行为性的:空值过不了校验的字段就是必填——不是 zod 的 optional() 结构(必填的 z.string().min(1) 与可留空的 z.string().max(100) 在结构上都"非 optional",TanStack Form 本身也没有必填概念)。

联动没有专属 API——信号是 Web 标准属性 aria-requiredfieldControlProps 从表单自带的 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 多选组的组标题——group role 不合法承载 aria-required,逐项 设置又是语义谎言;
  • InfiniteCombobox 的 label——触发器是 button role,同样不承载。

不经表单的场景仍可用底层的 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 / onValueChangeid 落在 SelectTrigger 上。

  • 清除选择时 onValueChangenull,统一折回 '' 再交给 field.handleChange
  • fieldControlProps 的整体展开不适用(nameSelect 根、id 归 trigger), 改用 fieldInvalidState 手工分发 aria-invalidaria-describedby

Cascader

与 Select 同型,只是值是整条路径value / onValueChange 收发 string[],空值是 nullid 落在 CascaderTrigger 上。

  • 表单里直接存 string[] | null,zod 写 z.array(z.string()).nullable().refine(path => path !== null, '…')—— 清除 ✕ 回的 null 原样入表,不折字符串。
  • nameCascader 根:原生提交时每个路径段序列化一个同名隐藏 input。

DatePicker

与 NumberField 同型:值原生是 Date | null,受控走 value / onValueChange, 不需要字符串解析——键入非法文本不落值,表单永远只见合法 Datenull

  • 清除 ✕ 或清空文本回 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,不是对象:单选的受控 valuestring | null,而 onValueChange 回传的是对象——表单里存 id(未加载页的预选项只有 id 没有对象), 展示文案用局部 state 存对象。
  • triggerIdFieldLabel htmlFor 的落点;name 会在触发器旁(弹层外) 渲染隐藏 input,弹层关闭不影响序列化。
  • schema 用 z.string().nullable().refine(v => v !== null, '…') 表达必选。

复杂表单

控件全家 × zod 常见形态的综合示例——一套门面接线贯穿所有绑定。

覆盖的 zod 形态,可按需取用:

  • 必填文本z.string().min(n, '…')(姓名、密码)
  • 邮箱z.email('…'),配 InputGroup 图标前缀
  • 数字控件:NumberField 的值原生是 number | nullz.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(短信验证码)
  • 异步选择存 idz.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、Stepper loading(当前步转 Spinner)、trigger 与「上一步」 一并拦下。pending 不是装饰而是防误交:「下一步」与「提交」渲染在同一个位置 (React 还会复用同一个 button 节点),推进完成的瞬间,快速双击的第二击就落在 「提交」上——没有 pending 拦着,表单就被误交了。
  • 回头随便走,前进必须过关。 Stepper 受控在外部 step 上;onValueChange 里 对 next > stepadvancing 期间的点击调 eventDetails.cancel(),点已完成的 步骤则直接放行。
  • 真正的提交只在最后一步formProps(form) 照常接管:失败揭示 + 聚焦 + 提交 管线全部是包默认。

API

@gedatou/cadenza-form 的完整导出。官方 API 全量转发,下表只列门面新增的部分:

导出说明
createFormHook(options?)覆盖官方同名导出:contexts 由包内单例注入,fieldComponents / formComponents 可省略,返回官方 { useAppForm, withForm, … }
useFieldContext / useFormContext包内单例 contexts 的取值 hook,供自定义字段组件使用
formProps(form, options?)<form> 的展开助手:{ noValidate: true, onSubmit: formSubmitHandler(form, options) }——统一关掉原生约束校验(它会在 submit 事件之前拦截提交),接通提交管线
formSubmitHandler(form, options?)<form onSubmit> 处理器:preventDefault + stopPropagation;失败提交后补跑一次完整校验(form-core 在字段级校验失败时会跳过表单级校验,新增字段会漏掉错误)、揭示错误字段(revealFieldErrors)并聚焦首个无效控件(focusFirstError: false 关闭)。也接受裸的 handleSubmit 回调,但该形态拿不到 form api,不含补校验与错误揭示
revealFieldErrors(form)把「有错误但未 blur」的字段标记为已 blur——展示门禁只依赖字段本地状态即可打开(submissionAttempts 住在表单 store,字段组件不订阅它)
focusFirstInvalidControl(form)聚焦表单内首个非禁用的 aria-invalid 控件,rAF 调度
fieldErrors(field)过门禁后的 { message } 数组,喂 FieldErrorerrors
fieldErrorMessage(field)第一条错误的 message,未过门禁为 undefined
fieldHasError(field)是否有可显示的错误
fieldShouldShowError(field)门禁本体:改过(dirty)且失焦过,或提交过
fieldErrorId(name)错误元素的 id:清洗非法字符后拼 -error
fieldInvalidState(field){ errorId, invalid },给 FieldErroridFielddata-invalid
fieldControlProps(field)展开 id / name / aria-describedby / aria-invalid / aria-required 五线(必填自动推导,可选字段省略该属性)
normalizeFieldErrors(errors)递归拍平错误值:字符串与带 message 的对象统一成 { message }
requiredFields(schema, emptyValues)底层必填探针:对空值同步跑一次 schema,出错字段路径集合(与 field.name 同格式)。表单内不需要它——fieldControlPropsaria-required 已内置同一推导,红星由 Field 家族 CSS 对该属性响应
silentFieldUpdateOptionssetFieldValue 第三参:不跑 listeners、不更新 meta、不校验
validatingFieldUpdateOptionssetFieldValue 第三参:只跳过 listeners,照常校验
useFormReset(form, defaultValues)defaultValues 引用变化时整表回填
useFormSubmitting(form)订阅 isSubmitting
FormFieldError / AppFieldControlProps错误条目与 aria 接线 props 的类型