多步骤表单仅提交最后一步的值——如何保留并提交所有步骤的值?
嘿,我之前也踩过这个坑!用react-hook-form + Zod做可复用多步表单的时候,经常会碰到切换步骤后之前填的值就丢了,最后提交只拿到最后一步数据的问题。其实核心解决思路就是不要让每个步骤单独管理表单状态,而是用一个全局的表单实例来统一存所有步骤的字段值,再配合Zod的schema组合来做验证。下面给你几个具体的实现方案,都是实际项目里能用的:
1. 统一用一个全局Form实例(最推荐的基础方案)
不要在每个步骤组件里单独调用useForm,而是在最外层的表单容器里初始化一次,把register、control这些方法传递给各个步骤组件。这样所有步骤的字段都会存在同一个form state里,切换步骤时自然不会丢失之前的值。
举个具体的代码例子:
首先组合所有步骤的Zod schema成一个总schema,同时定义好所有字段的默认值:
import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { useState } from 'react'; // 第一步的字段schema const Step1Schema = z.object({ firstName: z.string().min(1, '请填写名字'), lastName: z.string().min(1, '请填写姓氏'), }); // 第二步的字段schema const Step2Schema = z.object({ email: z.string().email('邮箱格式不正确'), phone: z.string().min(10, '手机号长度不够'), }); // 合并成总schema const TotalFormSchema = Step1Schema.merge(Step2Schema); type FormValues = z.infer<typeof TotalFormSchema>; export default function MultiStepForm() { // 全局初始化一次useForm,用总schema做校验 const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(TotalFormSchema), defaultValues: { firstName: '', lastName: '', email: '', phone: '', }, }); const [currentStep, setCurrentStep] = useState(1); // 提交时就能拿到所有步骤的完整数据 const onSubmit = (data: FormValues) => { console.log('所有步骤的提交数据:', data); // 这里写你的提交逻辑 }; return ( <form onSubmit={handleSubmit(onSubmit)}> {currentStep === 1 && ( <div className="step-container"> <h3>第一步:基本信息</h3> <div> <label>名字:</label> <input {...register('firstName')} /> {errors.firstName && <p className="error">{errors.firstName.message}</p>} </div> <div> <label>姓氏:</label> <input {...register('lastName')} /> {errors.lastName && <p className="error">{errors.lastName.message}</p>} </div> <button type="button" onClick={() => setCurrentStep(2)}>下一步</button> </div> )} {currentStep === 2 && ( <div className="step-container"> <h3>第二步:联系方式</h3> <div> <label>邮箱:</label> <input {...register('email')} /> {errors.email && <p className="error">{errors.email.message}</p>} </div> <div> <label>手机号:</label> <input {...register('phone')} /> {errors.phone && <p className="error">{errors.phone.message}</p>} </div> <button type="button" onClick={() => setCurrentStep(1)}>上一步</button> <button type="submit">提交表单</button> </div> )} </form> ); }
这个方案的好处是逻辑简单,所有字段都在同一个状态里管理,提交时直接拿完整数据,而且Zod的校验也能覆盖所有字段。
2. 分步骤验证(想先过当前步才能进下一步的情况)
如果需要做“必须通过当前步骤的校验才能进入下一步”的逻辑,不用直接用总schema的resolver,而是在切换步骤时单独触发当前步骤字段的校验,提交时再校验所有字段。
比如修改上面的切换步骤逻辑:
// 全局初始化useForm时暂时不设置总resolver,或者用宽松的默认 const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormValues>({ defaultValues: { firstName: '', lastName: '', email: '', phone: '', }, }); // 切换到第二步前先校验第一步的字段 const goToStep2 = async () => { const isStep1Valid = await trigger(['firstName', 'lastName']); if (isStep1Valid) { setCurrentStep(2); } }; // 切换回第一步直接切就行,不用校验 const goToStep1 = () => { setCurrentStep(1); }; // 提交时校验所有字段 const onSubmit = handleSubmit(async (data) => { const isAllValid = await trigger(); if (isAllValid) { console.log('所有校验通过的完整数据:', data); // 执行提交 } });
这样既实现了分步骤的校验拦截,又能保证所有步骤的字段值都被保留在全局form state里。
3. 适配你的自定义组件(FormWrapper、RHFInput)
确保你的自定义表单组件是接收外层传递的register、control等props,而不是在组件内部自己初始化useForm。比如你的RHFInput应该是这样的:
import { UseFormRegisterReturn } from 'react-hook-form'; type RHFInputProps = { name: string; register: UseFormRegisterReturn; label?: string; }; export default function RHFInput({ name, register, label }: RHFInputProps) { return ( <div className="form-input"> {label && <label htmlFor={name}>{label}</label>} <input id={name} {...register} /> </div> ); }
然后在步骤组件里使用时,传递外层的register方法:
// 在第一步里用RHFInput <RHFInput name="firstName" register={register('firstName')} label="名字" />
这样自定义组件的字段也会注册到全局的form state里,不会出现独立的状态。
避坑提醒
- 绝对不要在每个步骤组件里单独调用
useForm,不然每个步骤都有自己的表单状态,切换时之前的数据肯定会丢。 - 不要在切换步骤时调用
reset方法,除非你真的想清空所有已填的值。 - 如果是动态增加的步骤(比如用户可以添加多个地址),可以用react-hook-form的
useFieldArray来管理动态字段,核心还是要基于全局的form state来操作。
内容来源于stack exchange

