Project Setup: Multi-Step Account Form
Everything in this course has been building toward this: a real, multi-step form that pulls together field validation, async checks, array fields, linked fields, and schema validation. Over the next several lessons we're going to build a 3-step account creation form from scratch.
What We're Building
A user signs up for an account across three steps:
- Step 1 — Personal Info: first name, last name, email, password, and confirm password
- Step 2 — Preferences: interests (a dynamic array), notification preference (email, SMS, or none), and a phone number that only appears when SMS is selected
- Step 3 — Review: a read-only summary of everything before the user hits submit
Each step is its own component, but they all share a single form instance. That's the key design decision here, and we'll come back to why in a moment.
The Data Shape
Here's the full TypeScript type for the form:
type AccountFormData = {
// Step 1
firstName: string
lastName: string
email: string
password: string
confirmPassword: string
// Step 2
interests: string[]
notificationPreference: 'email' | 'sms' | 'none'
phone: string
}
All of this lives in one object. One form. One submission handler.
The Multi-Step Strategy
The approach is straightforward: track the current step with useState, and conditionally render the right step component. useForm is called once at the top level, and the form instance gets passed down to each step as a prop.
function AccountForm() {
const [currentStep, setCurrentStep] = useState(0)
const form = useForm({
defaultValues: {
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
interests: [] as string[],
notificationPreference: 'email' as const,
phone: '',
},
onSubmit: async ({ value }) => {
// Submit to API
console.log('Account created:', value)
},
})
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
{currentStep === 0 && <StepPersonalInfo form={form} />}
{currentStep === 1 && <StepPreferences form={form} />}
{currentStep === 2 && <StepReview form={form} />}
<div>
{currentStep > 0 && (
<button type="button" onClick={() => setCurrentStep((s) => s - 1)}>
Back
</button>
)}
{currentStep < 2 ? (
<button type="button" onClick={() => setCurrentStep((s) => s + 1)}>
Next
</button>
) : (
<button type="submit">Create Account</button>
)}
</div>
</form>
)
}
The navigation buttons live outside the step components. "Next" increments the step, "Back" decrements it, and "Create Account" triggers the actual form submission on the final step.
Why One Form Instance
You might wonder why not create a separate form for each step and merge the results at the end. You could, but one form instance is simpler in every way.
All the field state lives in one place. When the user hits the review step, you can read form.state.values and display everything without any merging or reconciliation. Cross-field validation — like checking that confirmPassword matches password — works naturally because both fields are in the same form. And there's only one onSubmit to wire up.
Multi-step forms feel complicated, but TanStack Form makes the state management trivial. The only thing that changes between steps is what you render. The form itself doesn't care.
What's Next
We'll build this out one piece at a time:
- Step 1: Personal info with field-level and cross-field validation
- Step 2: Preferences with an array field and a conditional phone input
- Step 3: A review component that reads from form state
- Final polish: Async email validation, Zod schemas, and proper error handling
By the end you'll have a production-ready pattern you can drop into any project.