patterntypescriptreactModerate
Multi-step form pattern — state machine with step tracking
Viewed 0 times
multi-step formwizard formstep validationform accumulatordefaultValuesuseFormContextform navigation
Problem
Multi-step forms (wizards) require tracking the current step, accumulating partial form data across steps, validating each step independently, and navigating back without losing data. Implementing this ad hoc leads to tangled useState and prop-drilling.
Solution
Maintain a form data accumulator at the top level; validate each step with independent Zod schemas:
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const step1Schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
});
const step2Schema = z.object({
email: z.string().email(),
phone: z.string().optional(),
});
type Step1 = z.infer<typeof step1Schema>;
type Step2 = z.infer<typeof step2Schema>;
type FormData = Step1 & Step2;
function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Partial<FormData>>({});
const step1Form = useForm<Step1>({
resolver: zodResolver(step1Schema),
defaultValues: formData,
});
const handleStep1 = step1Form.handleSubmit((data) => {
setFormData((prev) => ({ ...prev, ...data }));
setStep(2);
});
if (step === 1) return (
<form onSubmit={handleStep1}>
<input {...step1Form.register('firstName')} placeholder="First name" />
<button type="submit">Next</button>
</form>
);
if (step === 2) return (
<StepTwo
initialData={formData}
onBack={() => setStep(1)}
onSubmit={(data) => finalSubmit({ ...formData, ...data } as FormData)}
/>
);
return null;
}
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const step1Schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
});
const step2Schema = z.object({
email: z.string().email(),
phone: z.string().optional(),
});
type Step1 = z.infer<typeof step1Schema>;
type Step2 = z.infer<typeof step2Schema>;
type FormData = Step1 & Step2;
function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Partial<FormData>>({});
const step1Form = useForm<Step1>({
resolver: zodResolver(step1Schema),
defaultValues: formData,
});
const handleStep1 = step1Form.handleSubmit((data) => {
setFormData((prev) => ({ ...prev, ...data }));
setStep(2);
});
if (step === 1) return (
<form onSubmit={handleStep1}>
<input {...step1Form.register('firstName')} placeholder="First name" />
<button type="submit">Next</button>
</form>
);
if (step === 2) return (
<StepTwo
initialData={formData}
onBack={() => setStep(1)}
onSubmit={(data) => finalSubmit({ ...formData, ...data } as FormData)}
/>
);
return null;
}
Why
Each step uses an independent form instance with its own schema, so validation is isolated to the current step. Accumulated data is stored in a parent state object and passed as defaultValues when returning to a previous step, preserving entered data.
Gotchas
- Pass formData as defaultValues when the user navigates back — RHF does not persist values across unmounts by default
- Consider using useFormContext from RHF to avoid prop-drilling in deeply nested step components
- For very complex wizards (5+ steps with conditional branching), XState or a dedicated step machine is more maintainable
- Validate the complete merged schema on final submission to catch any cross-step inconsistencies
Revisions (0)
No revisions yet.