Lesson 16

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:

  1. Step 1: Personal info with field-level and cross-field validation
  2. Step 2: Preferences with an array field and a conditional phone input
  3. Step 3: A review component that reads from form state
  4. 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.