Solid 多步骤表单构建指南:TanStack Form 的 FormGroup 子表单校验与提交
【免费下载链接】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(Solid 适配器)的 Form Group(表单分组)为核心,讲解如何用
<form.FormGroup>把大型多步骤表单拆分为独立的子表单,并让每个子表单拥有独立的校验、错误分发与提交语义。读完本文,你将掌握 FormGroup 的声明式用法、组级校验与标准 Schema 组合策略、onDynamic动态校验的推荐姿势,以及group().state.meta聚合状态的使用方法,可直接套用到多步骤向导(Multi-step Wizard)等真实场景。
为什么需要 FormGroup:多步骤表单的拆分痛点
当构建一个包含多个步骤的表单(如分步向导、流程式填写页面)时,每个步骤如果各自维护一个独立的"顶层表单",往往会把表单提交与校验流程复杂化——你需要手工拼接各步骤的数据、分别触发校验、再协调最终提交,逻辑冗长且容易出错。
TanStack Form 为此提供了内建的子表单能力:<form.FormGroup>。它允许你在一个统一的form实例内部声明多个"子表单"(sub-form),每个子表单拥有近似于表单的 API(如deleteField、insertFieldValue、handleSubmit),同时又共享父表单的单一状态树,让这类开发变得非常简单。
如下是一个典型的多步骤向导界面,这正是 FormGroup 最典型的应用场景:
基本用法:声明一个 FormGroup
使用 FormGroup 的方式和Field几乎一致:通过createForm(或useAppForm,即createFormHook组合出来的表单)创建form变量,然后引用其FormGroup组件:
const form = createForm(() => ({ defaultValues: { step1: { name: '', }, step2: { age: 0, }, }, })) return ( <form.FormGroup name="step1"> {(group) => ( // `group()` 拥有完整的表单类方法, // 例如 `deleteField`、`insertFieldValue` 等 // ... )} </form.FormGroup> )这里有几个关键点:
name必须指向defaultValues中的一个深层路径(DeepKeys<TParentData>),它确定了该分组在父表单状态树中的挂载位置;- 渲染函数(render prop)接收一个访问器(accessor)
group,调用group()会得到一个FormGroupApi实例; FormGroupApi同时实现了FormLikeAPI与FieldLikeAPI(见 FormGroupApi.ts),所以它既具备表单的方法(如handleSubmit),也具备字段的方法(如setValue、validate),并且所有操作最终都委托给父表单的FormApi(如deleteField内部调用this.form.deleteField)。
在 Solid 适配器中,form.FormGroup是createForm返回的扩展 API 的一部分,实现位于 createForm.tsx:extendedApi.FormGroup = (props) => <FormGroup {...props} form={api} />。FormGroup组件内部通过createFormGroup创建FormGroupApi,在onMount时调用api.mount()注册到父表单(卸载时清理),并用createComputed在每次渲染前同步最新配置,具体见 createFormGroup.tsx。
与外部状态配合:条件渲染多步骤向导
FormGroup 真正强大之处在于可以和外部状态配合,按步骤条件渲染,实现"每步一个子表单"的向导:
const [step, setStep] = createSignal(0) const form = createForm(() => ({ defaultValues: { step1: { name: '', }, step2: { age: 0, }, }, })) return ( <> <Show when={step() === 0}> <form.FormGroup name="step1" onGroupSubmit={() => { // 校验通过后推进步骤 setStep(step() + 1) }} onGroupSubmitInvalid={() => { // 处理校验未通过的提交,和顶层表单的行为一致 }} onSubmitMeta={{} as SomeType} > {(group) => ( // 使用 `group().handleSubmit()` 提交子表单,但不提交父表单 // ... )} </form.FormGroup> </Show> <Show when={step() === 1}> <form.FormGroup name="step2"> {(group) => ( // 在最后一步,使用 `form.handleSubmit()` 提交整个表单 // ... )} </form.FormGroup> </Show> </> )这种模式的语义非常清晰:
- 中间步骤调用
group().handleSubmit(),只校验并提交当前分组,通过后由onGroupSubmit推进步骤; - 最后一步调用
form.handleSubmit(),提交整个父表单(此时父表单会校验所有字段,包括此前步骤中已渲染过的分组数据)。
从源码看,FormGroupApi._handleSubmit的流程是(见 FormGroupApi.ts):先递增submissionAttempts、标记所有相关字段为 touched → 对所有相关字段执行submit校验(validateAllFields('submit'))→ 若字段无效,调用onGroupSubmitInvalid并终止 → 再执行分组自身的submit校验 → 若分组或字段仍无效则调用onGroupSubmitInvalid→ 全部通过后依次触发相关字段的onGroupSubmit监听器、分组的onSubmit监听器,最后执行onGroupSubmit回调并把isSubmitted/isSubmitSuccessful置为true。这就是"子表单提交不影响父表单"的底层保证:_handleSubmit全程只操作this.form中与本分组相关的字段,绝不调用父表单的handleSubmit。
真实示例:multi-step-wizard
仓库中的 multi-step-wizard 示例 完整演示了上述模式。它以createFormHook组装出useAppForm/withForm(见 hooks/form.tsx),公共配置通过formOptions声明(见 shared-form.tsx):
export const step1Schema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), }) export const step2Schema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters'), }) export const wizardFormOpts = formOptions({ defaultValues: { step1: { name: '' }, step2: { name: '' }, }, })第一步子表单(step1-subform.tsx)把onDynamic: step1Schema挂在分组上,通过onGroupSubmit推进步骤;第二步子表单(step2-subform.tsx)在onGroupSubmit中调用props.form.handleSubmit()完成整表提交。页面层(page.tsx)用createSignal+<Show>按step条件渲染两个子表单,父表单的onDynamic只负责整表提交时校验完整 Schema:
const form = useAppForm(() => ({ ...wizardFormOpts, validationLogic: revalidateLogic(), validators: { // onDynamic 仅在 `form.handleSubmit` 被调用时生效; // 调用 FormGroup 的 `handleSubmit` 时只会校验当前步骤的 Schema。 onDynamic: z.object({ step1: step1Schema, step2: step2Schema, }), }, onSubmit: ({ value }) => { alert(`Form submitted: ${JSON.stringify(value)}`) }, }))Form Group 校验:子表单自己的校验管线
FormGroup 拥有区别于普通字段的独立校验流程,专门为子表单设计,主要体现在三个方面。
1. 分组可以有自己的校验器
和字段一样,FormGroup 支持validators,可直接读取分组级别的错误映射:
<form.FormGroup name="step1" validators={{ onChange: () => 'Error' }}> {(group) => { group().state.meta.errorMap // {onChange: "Error" | undefined} group().state.meta.errors // ("Error")[] }} </form.FormGroup>FormGroupValidators完整支持以下配置项(见 FormGroupApi.ts):
| 配置项 | 作用 |
|---|---|
onMount | 分组挂载时运行的同步校验 |
onChange | 值变化时运行的同步校验 |
onChangeAsync/onChangeAsyncDebounceMs | 变化时的异步校验及防抖毫秒数 |
onBlur/onBlurAsync/onBlurAsyncDebounceMs | 失焦时的同步 / 异步校验 |
onSubmit/onSubmitAsync | 提交时的同步 / 异步校验 |
onDynamic/onDynamicAsync/onDynamicAsyncDebounceMs | 动态(Schema 驱动)校验 |
此外,分组还支持canSubmitWhenInvalid(允许无效状态下提交)、validationLogic(覆盖父表单的校验策略,默认继承父表单或defaultValidationLogic)、listeners(含onChange、onBlur、onMount、onUnmount、onSubmit、onGroupSubmit)以及onSubmitMeta、onGroupSubmit、onGroupSubmitInvalid等选项。
2. 可以把错误设置到子字段上
分组校验器可以返回一个{ group, fields }形状的对象,其中fields中的 key 使用相对于该分组的字段名,从而把错误分发(distribute)到对应的子字段:
<form.FormGroup name="step1" validators={{ onChange: ({ value, groupApi }) => ({ group: value.name === 'error' ? 'Group error' : undefined, fields: { // 必须使用相对 FormGroup 的字段名作为错误 key, // 以便与标准 schema 在分组上的工作方式保持一致 name: value.name === 'error' ? 'Field error' : undefined, }, }), }} />这一分发逻辑在源码中由distributeFieldErrors实现(见 FormGroupApi.ts):它通过buildChildFieldName把相对字段名(支持name、nested.value点号形式和[0].name括号形式)转成完全限定名(fully-qualified name),写入子字段的errorMap/errorSourceMap,并记录"上一次分发过的字段名",以便在后续校验运行时清除过期错误,同时不会覆盖父表单校验器设置的错误。
3. 直接接受标准 Schema(如 Zod)
FormGroup 的校验器与标准 Schema(Standard Schema)天然兼容,可以直接传入 Zod 对象:
<form.FormGroup name="step1" validators={{ onChange: z.object({ name: z.string().min(2), }), }} />从实现上看,runValidator会先通过isStandardSchemaValidator识别标准 Schema,再用standardSchemaValidators执行,并把结果 remap 成{ group, fields }形状以接入分组错误分发管线(见 FormGroupApi.ts 与 remapStandardSchemaResultForGroup)。手动函数校验器则直接返回{ group, fields }。
为什么字段错误 key 使用相对路径?
FormGroup 刻意不使用字段的完整路径名,目的是让你能够像搭积木一样组合 Schema:
const step1Schema = z.object({ name: z.string().min(2) }) const schema = z.object({ step1: step1Schema, step2: step2Schema })然后把step1Schema传给对应的 FormGroup、把schema传给父表单。这样,即使某个分组被绕过(例如用户直接提交整表),部分校验过的数据也仍然会在对应位置报错——两套 Schema 无缝共享同一份校验语义,避免了"分组能过、整表不过"的错位问题。
动态分组校验:把 Schema 挂在 FormGroup 上而非父表单
如果要在 FormGroup 上使用动态校验(onDynamic),请不要依赖createForm上传入的onDynamic校验器:
createForm(() => ({ validationLogic: revalidateLogic(), validators: { // 注意:当子表单被提交时,该校验器不会运行 `onChange`; // 它只会在表单自身被提交时运行 `onChange`。 onDynamic: schema, }, }))正确做法是把分组对应的子 Schema 传给FormGroup 自身的onDynamic:
<form.FormGroup validators={{ onDynamic: step1Schema }} />此时,group().submissionAttempts会成为切换"提交前 / 提交后"校验策略的依据——这正是revalidateLogic()配合onDynamic判断校验源('field'与'form')的核心机制:分组第一次提交前按"字段级"逻辑校验,提交失败后按"表单级"逻辑继续校验,确保向导场景下"每步各自校验、最终整表兜底"。
multi-step-wizard 示例正是这种做法的教科书级实现:每个分组分别挂onDynamic: step1Schema/onDynamic: step2Schema,父表单只保留整表提交用的组合 Schema(见 page.tsx 与 step1-subform.tsx)。
Form Group 状态与 meta 聚合标志
除了group().state.meta.errors,你还可以通过group().state.value读取分组的当前值(它等价于父表单状态树中name路径下的子对象)。而group().state.meta中最有价值的是下面几个聚合后的校验标志:
| meta 属性 | 含义 |
|---|---|
group().state.meta.isFieldsValid | 当所有字段级校验器均无错误时为true |
group().state.meta.isGroupValid | 当分组自身的校验器无错误时为true |
group().state.meta.isValid | 当字段级与分组级校验器均无错误时为true |
group().state.meta.isSubmitting | 当分组正在提交过程中时为true |
这些标志都定义在FormGroupMeta接口中(见 FormGroupApi.ts):isFieldsValidating、isFieldsValid、isGroupValid、isValid、canSubmit,并继承FormGroupState中的isSubmitting、isSubmitted、isValidating、submissionAttempts、isSubmitSuccessful。FormGroupApi的store只保存{ value, meta }两份最小状态,聚合推导全部放在父表单的formGroupMetaDerived中完成(见 FormGroupApi.ts),getRelatedFieldMetasDerived会跳过分组自身的条目(避免把组级校验与字段级校验混为一谈),并通过isFieldInGroup收集分组下的所有子字段 meta 进行聚合。
在实际向导中,isSubmitting非常适合用来做"当前步骤提交中"的按钮 loading 状态;isValid/isFieldsValid/isGroupValid则可以驱动"下一步按钮是否可点"或分步指示器的完成态。
小结
TanStack Form 的FormGroup把"多步骤表单"这一高频需求收敛成了声明式的子表单方案:
- 用
<form.FormGroup name="...">拆分状态树,分组自带表单类 API(handleSubmit、deleteField、insertFieldValue等); - 中间步骤用
group().handleSubmit()局部提交并推进流程,最后一步用form.handleSubmit()整表提交; - 分组校验器既可以是函数(返回
{ group, fields }分发字段错误),也可以是标准 Schema(Zod 等),字段 key 使用相对分组路径以支持 Schema 组合复用; - 动态校验请把子 Schema 挂在分组自身的
onDynamic上,配合revalidateLogic()实现"分组内局部校验、整表提交兜底"; state.meta提供isFieldsValid、isGroupValid、isValid、isSubmitting等聚合标志,可无缝驱动 UI 状态。
如果想在真实项目中上手,可以直接参考仓库的 multi-step-wizard 示例,对照其shared-form.tsx、step1-subform.tsx、step2-subform.tsx与page.tsx四份文件,即可拼出一个完整、健壮的分步向导表单。
【免费下载链接】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),仅供参考