Lesson 16

Project Setup — Multi-Step Event Registration

We've covered individual features of React Hook Form — validation, schema integration, context, field arrays. Now it's time to combine them. Over the next five lessons we'll build a multi-step event registration form with three steps: Attendee Info, Tickets & Guests, and Preferences & Review. This lesson sets up the architecture: the Zod schema, the form instance, and the step navigation scaffold.

What We're Building

The finished form walks users through three screens:

  1. Attendee Info — name, email, optional phone number
  2. Tickets & Guests — ticket type selection plus a dynamic list of guest names and emails
  3. Preferences & Review — dietary restrictions, accessibility needs, and a summary before submission

Each step is its own component. The user clicks "Next" to advance and "Back" to return. Only the final step has a "Submit" button. Validation runs per-step — if step 1 has errors, the user can't move to step 2.

Architecture: One Form, Multiple Steps

There are two common approaches to multi-step forms. The first creates a separate useForm for each step, collecting partial data and merging it at the end. The second uses a single useForm shared across all steps through FormProvider. We're going with the single-form approach:

  • Validation state persists. Navigate back to step 1 and the data is still there. With separate forms, you'd store intermediate values yourself and reinitialize each time.
  • One submit handler. All data lives in one place. handleSubmit on the final step gives you the complete object — no merging partial results.
  • Simpler data flow. Every step component calls useFormContext(). No prop drilling beyond what React Hook Form already provides.

The tradeoff is that all fields exist from the start, even ones not yet visible. That's fine — we control when validation fires using trigger().

The Full Schema

Define the complete schema upfront. Every field across all three steps lives here:

import { z } from "zod";

const eventSchema = z.object({
  // Step 1: Attendee Info
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  phone: z.string().optional(),

  // Step 2: Tickets & Guests
  ticketType: z.enum(["general", "vip", "student"], {
    required_error: "Select a ticket type",
  }),
  guests: z
    .array(
      z.object({
        name: z.string().min(1, "Guest name is required"),
        email: z.string().email("Invalid email"),
      })
    )
    .optional(),

  // Step 3: Preferences
  dietary: z
    .enum(["none", "vegetarian", "vegan", "gluten-free"])
    .default("none"),
  accessibility: z.string().optional(),
});

type EventFormValues = z.infer<typeof eventSchema>;

Declaring everything in one schema means TypeScript knows the full shape of the form data. EventFormValues captures all fields, and every step component benefits from the same type.

Per-Step Validation with trigger()

The key to per-step validation is trigger(). It validates specific fields without submitting the form. We map each step index to its fields, then call trigger with only those fields before advancing. If any fail, trigger returns false and populates formState.errors — the user sees errors and stays on the current step. We never validate the whole schema until final submission.

The Scaffold

Here's the complete main component. The step components are placeholders — we'll build them in the next three lessons:

import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// eventSchema and EventFormValues defined above

const stepFields: Record<number, (keyof EventFormValues)[]> = {
  0: ["name", "email", "phone"],
  1: ["ticketType", "guests"],
  2: ["dietary", "accessibility"],
};

const stepLabels = ["Attendee Info", "Tickets & Guests", "Preferences & Review"];

function StepAttendeeInfo() {
  return <div>Step 1: Attendee Info (coming next)</div>;
}
function StepTickets() {
  return <div>Step 2: Tickets & Guests (coming soon)</div>;
}
function StepPreferences() {
  return <div>Step 3: Preferences & Review (coming soon)</div>;
}

const steps = [StepAttendeeInfo, StepTickets, StepPreferences];

function EventRegistration() {
  const [currentStep, setCurrentStep] = useState(0);

  const methods = useForm<EventFormValues>({
    resolver: zodResolver(eventSchema),
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      ticketType: undefined,
      guests: [],
      dietary: "none",
      accessibility: "",
    },
    mode: "onTouched",
  });

  const { handleSubmit, trigger } = methods;

  async function handleNext() {
    const isValid = await trigger(stepFields[currentStep]);
    if (isValid) setCurrentStep((prev) => prev + 1);
  }

  function handleBack() {
    setCurrentStep((prev) => prev - 1);
  }

  function onSubmit(data: EventFormValues) {
    console.log("Registration submitted:", data);
  }

  const StepComponent = steps[currentStep];

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <nav style={{ display: "flex", gap: "16px", marginBottom: "24px" }}>
          {stepLabels.map((label, index) => (
            <span
              key={label}
              style={{
                fontWeight: index === currentStep ? "bold" : "normal",
                color: index === currentStep ? "#000" : "#999",
              }}
            >
              {index + 1}. {label}
            </span>
          ))}
        </nav>

        <StepComponent />

        <div style={{ display: "flex", gap: "8px", marginTop: "24px" }}>
          {currentStep > 0 && (
            <button type="button" onClick={handleBack}>Back</button>
          )}
          {currentStep < steps.length - 1 ? (
            <button type="button" onClick={handleNext}>Next</button>
          ) : (
            <button type="submit">Submit Registration</button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

export default EventRegistration;

useForm creates the form instance with the Zod resolver and default values for every field. FormProvider wraps the form so step components can call useFormContext(). The steps array maps indices to components, and StepComponent renders whichever one matches currentStep.

The button row shows "Back" on all steps except the first, "Next" on the first two, and "Submit Registration" on the last. "Next" is type="button" — it calls handleNext which validates the current step's fields via trigger() and only advances if they pass. The submit button is type="submit", triggering handleSubmit for one final full-schema validation.

The mode: "onTouched" setting means fields validate after the user blurs away. No errors appear until the user has actually interacted with a field. Combined with trigger() on "Next", it catches anything skipped.

The next three lessons will replace the placeholder components with real implementations: StepAttendeeInfo with text inputs, StepTickets with enum selection and a dynamic guest list via useFieldArray, and StepPreferences with dietary options and a final review summary.