Lesson 19

Building Step 3 — Preferences and Review

Step 3 is the final stop in the multi-step event registration form. It has two jobs: collect the attendee's preferences (dietary needs, accessibility requirements) and show a complete review summary of everything they've entered across all three steps. The user reads through the summary, optionally jumps back to edit earlier sections, and hits Submit. This is where useWatch earns its keep — it watches the entire form and feeds the review panel with live data, and it's where the form finally submits.

Preference Fields

The preferences section has two fields. A <select> for dietary preference offers four options — None, Vegetarian, Vegan, and Gluten-Free — and registers with register("dietary"). An optional <textarea> for accessibility needs registers with register("accessibility"). Neither field needs special validation rules. The select defaults to "None" since it's the first option, and the textarea is free text with no constraints.

Review Summary with useWatch

The review section needs to display every field from every step. Rather than passing individual values as props, we use useWatch with no name argument — this watches the entire form and returns all current values as an object:

const formData = useWatch({ control });

Every time any field changes anywhere in the form, the component re-renders with the latest values. That's exactly what you want for a review panel — it should always reflect the current state of the form. Each section in the review gets an "Edit" button that calls an onEdit callback with the target step number, so the parent component can jump the user back to fix something. When they navigate forward again, useWatch shows the updated data instantly.

The Complete StepPreferences Component

Here's the full component — preference fields, review summary, and the final Submit button:

import { useFormContext, useWatch } from "react-hook-form";
// EventFormValues type from the schema defined in the Project Setup lesson

function StepPreferences({ onEdit }: { onEdit: (step: number) => void }) {
  const {
    register,
    control,
    formState: { isSubmitting },
  } = useFormContext<FormValues>();

  const formData = useWatch({ control });

  return (
    <div>
      {/* Preference Fields */}
      <fieldset>
        <legend>Preferences</legend>
        <div>
          <label htmlFor="dietary">Dietary Preference</label>
          <select id="dietary" {...register("dietary")}>
            <option value="none">None</option>
            <option value="vegetarian">Vegetarian</option>
            <option value="vegan">Vegan</option>
            <option value="gluten-free">Gluten-Free</option>
          </select>
        </div>

        <div>
          <label htmlFor="accessibility">Accessibility Needs</label>
          <textarea
            id="accessibility"
            rows={3}
            placeholder="Any accessibility requirements? (optional)"
            {...register("accessibility")}
          />
        </div>
      </fieldset>

      {/* Review Summary */}
      <div style={{ border: "1px solid #ccc", borderRadius: 8, padding: 16, marginTop: 16 }}>
        <h3>Review Your Registration</h3>

        <section>
          <div style={{ display: "flex", justifyContent: "space-between" }}>
            <h4>Attendee Information</h4>
            <button type="button" onClick={() => onEdit(0)}>Edit</button>
          </div>
          <p><strong>Name:</strong> {formData.name || "—"}</p>
          <p><strong>Email:</strong> {formData.email || "—"}</p>
        </section>

        <hr />

        <section>
          <div style={{ display: "flex", justifyContent: "space-between" }}>
            <h4>Ticket Details</h4>
            <button type="button" onClick={() => onEdit(1)}>Edit</button>
          </div>
          <p><strong>Ticket Type:</strong> {formData.ticketType || "—"}</p>
          {formData.guests && formData.guests.length > 0 ? (
            <div>
              <strong>Guests:</strong>
              <ul>
                {formData.guests.map((guest, i) => (
                  <li key={i}>{guest.name}{guest.email}</li>
                ))}
              </ul>
            </div>
          ) : (
            <p><strong>Guests:</strong> None</p>
          )}
        </section>

        <hr />

        <section>
          <div style={{ display: "flex", justifyContent: "space-between" }}>
            <h4>Preferences</h4>
            <button type="button" onClick={() => onEdit(2)}>Edit</button>
          </div>
          <p><strong>Dietary:</strong> {formData.dietary || "None"}</p>
          <p><strong>Accessibility:</strong> {formData.accessibility || "No special requirements"}</p>
        </section>
      </div>

      {/* Submit Button */}
      <button type="submit" disabled={isSubmitting} style={{ marginTop: 16 }}>
        {isSubmitting ? "Submitting..." : "Complete Registration"}
      </button>
    </div>
  );
}

export default StepPreferences;

A few things to notice. This step renders <button type="submit"> instead of the "Next" button that Steps 1 and 2 used. When the user clicks "Complete Registration", the browser's native form submission fires and the parent's handleSubmit(onSubmit) wrapper intercepts it. The isSubmitting flag from formState disables the button during async submission to prevent double-clicks.

The useWatch({ control }) call with no name returns the entire form state. The review reads formData.name, formData.email, formData.ticketType, formData.guests, formData.dietary, and formData.accessibility — everything from all three steps. The Edit buttons call onEdit with the step number, and the parent's setCurrentStep handles navigation.

Wiring It Into the Parent Form

The parent form component renders each step conditionally and passes onEdit to Step 3:

function EventRegistrationForm() {
  const methods = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: "",
      email: "",
      ticketType: "general",
      guests: [],
      dietary: "none",
      accessibility: "",
    },
  });

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

  const onSubmit = (data: EventFormValues) => {
    console.log("Registration complete:", data);
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {currentStep === 0 && <StepAttendeeInfo />}
        {currentStep === 1 && <StepTickets />}
        {currentStep === 2 && <StepPreferences onEdit={(step) => setCurrentStep(step)} />}
      </form>
    </FormProvider>
  );
}

When Step 3's Submit button is clicked, methods.handleSubmit(onSubmit) validates the entire form — every field from every step. The Zod schema checks all of it in one pass. If the user jumped back to edit Step 1 and cleared a required field, the error surfaces here. If everything passes, onSubmit receives the complete data object.

The onEdit prop is just setCurrentStep in disguise. When the user clicks "Edit" next to "Attendee Information", onEdit(0) fires, currentStep becomes 0, and Step 1 renders with the existing values still intact. React Hook Form preserves all field values across step changes because the form state lives in the useForm instance, not in the DOM.

What the Final Data Looks Like

When onSubmit fires, the data object contains everything from all three steps:

{
  "name": "Jane Smith",
  "email": "jane@example.com",
  "ticketType": "vip",
  "guests": [
    { "name": "John Smith", "email": "john@example.com" },
    { "name": "Sarah Connor", "email": "sarah@example.com" }
  ],
  "dietary": "vegetarian",
  "accessibility": "Wheelchair access needed"
}

The guests array might be empty if the user didn't add any. The dietary field defaults to "none". The accessibility field could be an empty string. Zod has already validated every piece — string lengths, email formats, guest array structure, enum values for dietary and ticket type. What arrives in onSubmit is guaranteed to match your schema.

This is the payoff of the multi-step architecture. One useForm call owns all the state. FormProvider distributes access to every step component. Each step registers its own fields. useWatch gives the review panel a live view of everything. And handleSubmit validates the whole thing at the end. Three steps, one form, one submit.