Lesson 17

Building Step 1 — Attendee Info

With the Zod schema defined and FormProvider wrapping our multi-step layout, we can build the first step. Step 1 collects attendee information: the person's full name, their email address, and an optional phone number. Because FormProvider is already in place from the parent component, this step component doesn't receive any props — it pulls everything it needs from context.

What Step 1 Collects

The attendee info step has three fields:

  • Name — required, minimum 2 characters. This comes from the name: z.string().min(2, "Name must be at least 2 characters") rule in our Zod schema.
  • Email — required, must be a valid email format. The schema handles this with email: z.string().email("Please enter a valid email").
  • Phone — optional. If the user provides a value, it should look like a valid phone number, but leaving it blank is fine.

All three validations live in the schema we defined in the previous lesson. The component itself doesn't repeat any validation logic — it just registers the fields and displays errors.

Accessing the Form with useFormContext

Since our parent component wraps everything in FormProvider, the step component calls useFormContext to access the form API. No props needed:

import { useFormContext } from "react-hook-form";

function StepAttendeeInfo() {
  const {
    register,
    formState: { errors },
  } = useFormContext();

  // ...
}

This is the same pattern we covered in the FormProvider lesson, but now it's doing real work. The register function connects each input to the shared form state. The errors object contains any validation failures, keyed by field name. Because the Zod resolver is configured on the parent's useForm call, all validation runs through the schema automatically.

The Complete Component

Here's the full StepAttendeeInfo implementation:

import { useFormContext } from "react-hook-form";

function StepAttendeeInfo() {
  const {
    register,
    formState: { errors },
  } = useFormContext();

  return (
    <div>
      <h2>Attendee Information</h2>

      <div>
        <label htmlFor="name">Full Name</label>
        <input id="name" {...register("name")} placeholder="Jane Smith" />
        {errors.name && (
          <p style={{ color: "red" }}>{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email")} placeholder="jane@example.com" />
        {errors.email && (
          <p style={{ color: "red" }}>{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="phone">Phone (optional)</label>
        <input id="phone" type="tel" {...register("phone")} placeholder="555-123-4567" />
        {errors.phone && (
          <p style={{ color: "red" }}>{errors.phone.message}</p>
        )}
      </div>
    </div>
  );
}

export default StepAttendeeInfo;

There are no validation rules in the register calls — the Zod schema handles all of that. No props come in from the parent. Each field is a label, an input wired up with register, and a conditional error message. The component's only job is rendering the UI for this step.

Inline Error Display

Each field checks errors.name, errors.email, or errors.phone and renders the message if it exists. The ?.message pattern is important — the error object for a field might be undefined (no error), so you need optional chaining before accessing message.

These messages come directly from the Zod schema. When the user leaves the name field empty or types a single character, Zod produces "Name must be at least 2 characters". When the email format is wrong, Zod produces "Please enter a valid email". The component doesn't define these strings — it just displays whatever the schema returns.

Per-Step Validation with trigger

The parent component controls navigation. When the user clicks "Next" on step 1, the parent calls trigger(["name", "email", "phone"]) — the same pattern we set up in the project scaffold. This validates only the attendee fields without touching steps 2 and 3. If any field fails, trigger returns false, errors appear inline, and the user stays on step 1. If all pass, the parent advances to step 2.

Why This Works

The component is focused and minimal because the architecture does the heavy lifting. FormProvider distributes the form API through context, so there are no props to manage. The Zod schema centralizes validation, so the component doesn't repeat rules. And trigger enables per-step validation without requiring separate form instances per step. Each piece — context, schema, trigger — handles one concern, and the step component just renders fields.