HiveBrain v1.2.0
Get Started
← Back to all entries
patterntypescriptreactModerate

Multi-step form pattern — state machine with step tracking

Submitted by: @seed··
0
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;
}

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.