React Hook Form 18 ๐ Multi-Step Forms โ Preserving state across wizard steps
The multi-step form is not a different form API. It is the single useForm instance whose fields are spread across the multiple screens, and the state preservation is the mechanism that keeps the instance alive while the screens mount and unmount. The React Hook Form documentation states the rule plainly: “Fields from earlier steps unmount when the step changes, but their values stay in the form because shouldUnregister defaults to false. Don’t set it to true here” . The default is the mechanism that preserves the values, and the shouldUnregister: true is the trap that silently deletes the earlier steps the moment the user advances.
The single-instance pattern is the other half. A wizard that uses the separate useForm per step creates the separate form instances, and the separate instances do not share the values. The FormProvider and the useFormContext are the mechanism that shares the one instance across the steps, and the sharing is the reason the final submission includes every step’s values . The step validation is the trigger call with the specific field names, and the trigger is the mechanism that validates only the current step without the later steps’ errors.
Key point: The multi-step form is one useForm instance shared through the FormProvider. The step components call the useFormContext instead of the useForm. The shouldUnregister must stay false (the default), or the earlier steps’ values disappear when the step unmounts. The step validation is the trigger call with the current step’s field names, and the final handleSubmit validates every field.
Why the multi-step form needs the single instance
The separate-instance problem. A wizard that calls the useForm inside each step creates the separate form instances. The step 1’s instance holds the step 1’s values, and the step 2’s instance holds the step 2’s values, and the two do not know about each other. The final submission sends only the last step’s values, and the earlier steps’ values are the missing . The FormProvider is the mechanism that shares the one instance, and the sharing is the reason the final submission includes every step.
The unmount problem. The step change unmounts the previous step’s inputs. The React Hook Form’s default shouldUnregister: false keeps the unmounted fields’ values in the form state, and the default is the mechanism that preserves the values . The shouldUnregister: true is the option that removes the unmounted fields from the values object, and the removal is the reason the earlier steps’ values disappear . The default is the correct choice for the wizard, and the shouldUnregister: true is the choice for the conditional fields that must not submit.
The step-validation problem. The handleSubmit validates every field, and the every-field validation is the wrong trigger for the step’s “Next” button. The trigger with the current step’s field names is the mechanism that validates only the current step, and the validation is the reason the later steps’ errors do not block the earlier steps . The getFieldsForStep is the mechanism that maps the step number to the field names, and the mapping is the reason the trigger knows which fields to validate .
The draft problem. The wizard can be long, and the user can leave the page. The subscribe with the skipRender: true is the mechanism that stores the draft without the re-render, and the storage is the reason the draft survives the reload . The defaultValues from the store is the mechanism that restores the draft, and the restore is the reason the user does not start over.
The navigation problem. The next() and the back() are the navigation functions, and the currentStep is the state that the UI reads. The FormWizard from the react-form-step-wizard package is the mechanism that provides the navigation, or the useState with the step number is the manual alternative . The navigation is the mechanism that moves between the steps, and the step state is the reason the UI shows the one step at a time.
a. The single instance and the step components
The useForm is called once, at the top of the wizard, and the FormProvider spreads the methods to the step components. The step components call the useFormContext and read the register and the formState from the context.
// The single instance and the FormProvider
import { useForm, FormProvider, useFormContext } from "react-hook-form";
type WizardFormValues = {
firstName: string;
lastName: string;
email: string;
company: string;
};
function WizardForm() {
// The single instance is created at the top of the wizard.
// The shouldUnregister stays false (the default).
const methods = useForm<WizardFormValues>({
mode: "onBlur",
defaultValues: {
firstName: "",
lastName: "",
email: "",
company: "",
},
});
return (
// The FormProvider shares the methods with the step components.
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => console.log(data))}>
<Step1 />
<Step2 />
</form>
</FormProvider>
);
}
function Step1() {
// The useFormContext reads the methods from the provider.
const {
register,
formState: { errors },
} = useFormContext<WizardFormValues>();
return (
<div>
<input {...register("firstName")} placeholder="First name" />
{errors.firstName && <span>{errors.firstName.message}</span>}
<input {...register("lastName")} placeholder="Last name" />
</div>
);
}
The useFormContext<WizardFormValues>() is the mechanism that reads the register and the formState from the provider. The step component does not accept the props, and the prop-less API is the reason the steps are clean . The FormProvider is placed around the <form> element, and the placement is the reason the provider scopes the form.
The separate useForm inside the step is the anti-pattern. The separate instance holds the separate values, and the final submission sends only the last step’s values . The FormProvider is the mechanism that avoids the anti-pattern, and the avoidance is the reason the wizard works.
b. The step validation and the navigation
The step validation is the trigger call with the current step’s field names. The getFieldsForStep maps the step number to the field names, and the trigger validates only those fields.
// The step validation
import { useState } from "react";
const STEP_FIELDS = {
1: ["firstName", "lastName", "email"],
2: ["company"],
} as const;
function WizardForm() {
const [currentStep, setCurrentStep] = useState(1);
const methods = useForm<WizardFormValues>({
mode: "onBlur",
defaultValues: { firstName: "", lastName: "", email: "", company: "" },
});
const handleNext = async () => {
// The trigger validates only the current step's fields.
// The later steps' errors do not block the earlier steps.
const fields = STEP_FIELDS[currentStep as keyof typeof STEP_FIELDS];
const isStepValid = await methods.trigger(fields);
if (isStepValid) {
setCurrentStep((prev) => Math.min(prev + 1, 2));
}
};
const handleBack = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => console.log(data))}>
{currentStep === 1 && <Step1 />}
{currentStep === 2 && <Step2 />}
{currentStep > 1 && (
<button type="button" onClick={handleBack}>Back</button>
)}
{currentStep < 2 ? (
<button type="button" onClick={handleNext}>Next</button>
) : (
<button type="submit">Submit</button>
)}
</form>
</FormProvider>
);
}
The methods.trigger(fields) is the mechanism that validates only the current step’s fields. The fields is the array of the field names from the STEP_FIELDS map, and the map is the mechanism that maps the step number to the fields . The isStepValid is the boolean that decides whether the step advances, and the decision is the reason the later steps’ errors do not block the earlier steps.
The shouldUnregister must stay false. The step change unmounts the previous step’s inputs, and the shouldUnregister: false is the mechanism that keeps the values in the form state . The shouldUnregister: true is the trap that deletes the values, and the deletion is the reason the final submission is missing the earlier steps .
The final handleSubmit validates every field, and the every-field validation is the mechanism that catches the earlier step’s error if the user somehow advanced without fixing it . The onInvalid handler is the mechanism that sends the user back to the step with the error, and the back-navigation is the reason the user can fix the error.
Complete Example Session
// ============================================
// PART 1: THE SINGLE INSTANCE
// ============================================
import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
function WizardForm() {
const [step, setStep] = useState(1);
// The single instance for the whole wizard.
// The shouldUnregister is false by default.
const methods = useForm<WizardFormValues>({
mode: "onBlur",
defaultValues: {
firstName: "",
lastName: "",
email: "",
company: "",
},
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => console.log(data))}>
{step === 1 && <Step1 />}
{step === 2 && <Step2 />}
</form>
</FormProvider>
);
}
// ============================================
// PART 2: THE STEP COMPONENT
// ============================================
import { useFormContext } from "react-hook-form";
function Step1() {
const { register, formState: { errors } } = useFormContext<WizardFormValues>();
return (
<div>
<input {...register("firstName")} />
{errors.firstName && <span>{errors.firstName.message}</span>}
<input {...register("email")} />
</div>
);
}
// ============================================
// PART 3: THE STEP VALIDATION
// ============================================
const STEP_FIELDS = {
1: ["firstName", "email"],
2: ["company"],
};
const handleNext = async () => {
const isStepValid = await methods.trigger(STEP_FIELDS[step]);
if (isStepValid) setStep((s) => s + 1);
};
// The trigger validates only the current step's fields.
// ============================================
// PART 4: THE SHOULD UNREGISTER TRAP
// ============================================
// โ The shouldUnregister: true deletes the earlier steps.
useForm({ shouldUnregister: true });
// โ
The default (false) keeps the values.
useForm();
// ============================================
// PART 5: THE FINAL SUBMISSION
// ============================================
const onSubmit = (data: WizardFormValues) => {
// The data includes the values from every step.
console.log(data);
};
// The handleSubmit validates every field.
// ============================================
// PART 6: THE DRAFT
// ============================================
// The subscribe with the skipRender stores the draft.
useEffect(() => {
const subscription = methods.subscribe({
formState: { values: true },
callback: ({ values }) => saveDraft(values),
});
return () => subscription.unsubscribe();
}, [methods]);
// ============================================
// PART 7: THE DRAFT RESTORE
// ============================================
// The defaultValues from the store restores the draft.
useForm({ defaultValues: storedDraft ?? emptyWizard });
// ============================================
// PART 8: THE NAVIGATION PACKAGE
// ============================================
import { FormWizard, Step, useWizard } from "react-form-step-wizard";
// The FormWizard provides the next() and the back().
// The Step defines the one step at a time.
// ============================================
// PART 9: THE SEPARATE INSTANCE ANTI-PATTERN
// ============================================
// โ The useForm inside each step creates the separate instances.
// The final submission sends only the last step's values.
// โ
The one useForm at the top and the useFormContext in the steps.
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The useForm is called once at the top.
// [ ] The FormProvider spreads the methods.
// [ ] The step components call the useFormContext.
// [ ] The shouldUnregister is false.
// [ ] The trigger validates only the current step.
// [ ] The final handleSubmit validates every field.
The ten parts covered the single instance, the step component, the step validation, the shouldUnregister trap, the final submission, the draft, the draft restore, the navigation package, the separate-instance anti-pattern, and the checklist. The through-line is that the single instance is the state preservation, and the shouldUnregister: false is the mechanism that keeps the values.
Quick Reference
The single-instance pattern
| Step | Mechanism |
|---|---|
| The form instance | The one useForm at the top |
| The sharing | The FormProvider and the useFormContext |
| The step rendering | The conditional render by the step state |
| The step validation | The trigger with the step’s field names |
| The final validation | The handleSubmit on every field |
The shouldUnregister values
| Value | Behaviour | Use for |
|---|---|---|
false (default) | The unmounted fields keep the values | The wizard |
true | The unmounted fields are removed | The conditional fields that must not submit |
The step validation
| Function | Purpose |
|---|---|
trigger(fields) | Validate the specific fields |
handleSubmit | Validate every field |
STEP_FIELDS | The step-to-fields map |
The draft persistence
| Mechanism | Purpose |
|---|---|
subscribe with skipRender | Store the draft without the re-render |
The defaultValues from the store | Restore the draft |
Best Practices
โ Do This:
// Use the single useForm at the top
const methods = useForm<WizardFormValues>(); // โ
// Share the methods through the FormProvider
<FormProvider {...methods}><form>...</form></FormProvider> // โ
// Read the methods in the step components
const { register } = useFormContext<WizardFormValues>(); // โ
// Validate only the current step
await methods.trigger(STEP_FIELDS[step]); // โ
โ Don’t Do This:
// Use the useForm inside each step
function Step1() { const form = useForm(); } // the separate instances // โ
// Set the shouldUnregister to true
useForm({ shouldUnregister: true }); // the earlier steps are deleted // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The final submission is missing the earlier steps | The shouldUnregister: true is set | Use the default false |
| The step 2 values are missing | The separate useForm is used per step | Use the one useForm at the top |
| The later steps’ errors block the earlier steps | The handleSubmit is used for the step | Use the trigger with the step fields |
| The draft is lost on reload | The subscribe is missing | Add the subscribe and the defaultValues restore |
Real-World Examples
1. The single instance
const methods = useForm<WizardFormValues>();
2. The FormProvider
<FormProvider {...methods}><form>...</form></FormProvider>
3. The useFormContext
const { register } = useFormContext<WizardFormValues>();
4. The step validation
await methods.trigger(["firstName", "email"])
5. The shouldUnregister default
useForm() // shouldUnregister is false by default
6. The final submission
methods.handleSubmit((data) => console.log(data))
7. The draft subscribe
methods.subscribe({ formState: { values: true }, callback: saveDraft })
8. The draft restore
useForm({ defaultValues: storedDraft })
9. The navigation package
<FormWizard onSubmit={onSubmit}><Step name="step1">...</Step></FormWizard>
10. The step state
const [step, setStep] = useState(1)
Visual
The Single Instance
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE SINGLE INSTANCE โ
โ โ
โ The WizardForm โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ useForm โ The one instance โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ FormProvider โ The context โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโดโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โ โStep1 โ โStep2 โ The useFormContext โ
โ โ โ โ โ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The shouldUnregister Trap
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE shouldUnregister TRAP โ
โ โ
โ โ
The default (false): โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The step 1 unmounts โ The values stay โ
โ โ The step 2 mounts โ The final submit includes them โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ The shouldUnregister: true: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The step 1 unmounts โ The values are deleted โ
โ โ The step 2 mounts โ The final submit is missing them โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Step Validation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE STEP VALIDATION โ
โ โ
โ The Next button โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ trigger(["email"]) โ The current step's fields โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโดโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โ โValid โ โInvalid โ โ
โ โ โ โ โ โ
โ โThe nextโ โThe โ โ
โ โstep โ โerrors โ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| The instance | The one useForm at the top |
| The sharing | The FormProvider and the useFormContext |
The shouldUnregister | false (the default) |
| The step validation | The trigger with the step’s fields |
| The final validation | The handleSubmit on every field |
| The draft | The subscribe with the skipRender |
| The draft restore | The defaultValues from the store |
Key takeaways:
- The multi-step form is one
useForminstance. TheFormProvidershares the instance with the step components, and theuseFormContextreads it. The separateuseFormper step creates the separate instances, and the final submission is missing the earlier steps . - The
shouldUnregistermust stayfalse. The step change unmounts the previous step’s inputs, and the defaultfalsekeeps the values in the form state. TheshouldUnregister: trueis the trap that deletes the earlier steps . - The step validation is the
triggerwith the step’s fields. Thetriggervalidates only the current step, and the later steps’ errors do not block the earlier steps . The finalhandleSubmitvalidates every field. - The draft is the
subscribeand thedefaultValues. Thesubscribewith theskipRenderstores the values without the re-render, and thedefaultValuesfrom the store restores them . - The navigation is the step state or the package. The
useStatewith the step number is the manual navigation, and theFormWizardfrom thereact-form-step-wizardis the package alternative .
Remember: The React Hook Form multi-step form is the single instance and the FormProvider. The useForm is called once, the steps call the useFormContext, and the shouldUnregister stays false. The trigger validates the current step, and the handleSubmit validates every field. The state preservation is the mechanism that keeps the earlier steps’ values, and the default is the reason it works.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!