Lesson 18

Building Step 2 — Tickets and Dynamic Fields

Step 1 collected the attendee's personal information. Step 2 handles what they're actually registering for -- the ticket type and any additional guests they want to bring along. This is where useFieldArray makes its first appearance in the capstone project. The guest list is a dynamic array of objects, and useFieldArray manages it cleanly without any manual state juggling.

What Step 2 Collects

This step has two sections. First, a ticket type selection -- a standard <select> dropdown with three options: General, VIP, and Student. Second, an optional list of additional guests, where each guest has a name and email. The user can add as many guests as they want or submit with none at all.

Both fields are already defined in the Zod schema from the project setup lesson:

const formSchema = z.object({
  // Step 1 fields...
  ticketType: z.enum(["general", "vip", "student"], {
    errorMap: () => ({ message: "Select a ticket type" }),
  }),
  guests: z.array(
    z.object({
      name: z.string().min(1, "Guest name is required"),
      email: z.string().email("Valid email required"),
    })
  ),
  // Step 3 fields...
});

The ticketType field uses z.enum so only the three valid options pass validation. The guests array validates each entry individually -- every guest needs a name and a valid email. There's no .min(1) on the array itself because bringing guests is optional.

The StepTickets Component

Since the parent EventRegistration component wraps everything in FormProvider, this step component accesses the form through useFormContext. It also needs useFieldArray for the guest list. Here's the complete component:

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

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

  const { fields, append, remove } = useFieldArray({
    control,
    name: "guests",
  });

  return (
    <div>
      <h2>Tickets</h2>

      <div>
        <label htmlFor="ticketType">Ticket Type</label>
        <select id="ticketType" {...register("ticketType")}>
          <option value="">Select a ticket type</option>
          <option value="general">General</option>
          <option value="vip">VIP</option>
          <option value="student">Student</option>
        </select>
        {errors.ticketType && (
          <p style={{ color: "red" }}>{errors.ticketType.message}</p>
        )}
      </div>

      <div>
        <h3>Additional Guests</h3>

        {fields.map((field, index) => (
          <div
            key={field.id}
            style={{
              display: "flex",
              gap: "8px",
              marginBottom: "8px",
              alignItems: "flex-start",
            }}
          >
            <div>
              <input
                {...register(`guests.${index}.name`)}
                placeholder="Guest name"
              />
              {errors.guests?.[index]?.name && (
                <p style={{ color: "red" }}>
                  {errors.guests[index].name.message}
                </p>
              )}
            </div>

            <div>
              <input
                type="email"
                {...register(`guests.${index}.email`)}
                placeholder="Guest email"
              />
              {errors.guests?.[index]?.email && (
                <p style={{ color: "red" }}>
                  {errors.guests[index].email.message}
                </p>
              )}
            </div>

            <button type="button" onClick={() => remove(index)}>
              Remove
            </button>
          </div>
        ))}

        <button
          type="button"
          onClick={() => append({ name: "", email: "" })}
        >
          Add Guest
        </button>
      </div>
    </div>
  );
}

export default StepTickets;

Here's how the pieces connect.

Ticket Type: A Native Select

The ticket type dropdown is a plain <select> element, so register handles it directly -- no Controller needed. register("ticketType") attaches onChange, onBlur, ref, and name to the element, same as a text input. The first <option> has an empty string value and serves as the placeholder. If the user tries to advance without choosing, Zod's z.enum rejects the empty string and the custom errorMap produces a readable "Select a ticket type" message.

useFieldArray with FormProvider

The guest list uses useFieldArray with control from useFormContext(). In earlier lessons we got control from useForm directly -- here it comes from context instead, but it's the same object. useFieldArray doesn't care where it came from.

The hook returns fields (the current array, each with an auto-generated id), append (adds to the end), and remove (deletes by index). Each guest is rendered with fields.map, always using field.id as the key to keep React's reconciliation correct when items are added or removed.

The field paths use template literals to target the correct slot: register(`guests.${index}.name`) and register(`guests.${index}.email`). These dot-notation paths resolve into the nested array structure RHF expects.

Nested Error Display

Errors for array fields live at errors.guests?.[index]?.name and errors.guests?.[index]?.email. The optional chaining is essential -- errors.guests might not exist, the specific index might not have errors, and the specific field within that index might be valid. Skip the ?. and you'll get a runtime error when the form is clean.

{errors.guests?.[index]?.name && (
  <p style={{ color: "red" }}>
    {errors.guests[index].name.message}
  </p>
)}

Once you've confirmed the error exists with optional chaining in the condition, you can access .message directly in the body -- if you got past the condition, the error object is guaranteed to be there.

Validating Before Advancing

The parent EventRegistration component controls navigation between steps. When the user clicks Next on Step 2, the parent calls trigger to validate only the fields that belong to this step:

const handleNext = async () => {
  const valid = await trigger(["ticketType", "guests"]);
  if (valid) setStep(3);
};

Passing an array of field names to trigger runs validation for just those fields. For the guests array, this validates every entry -- each guest's name and email are checked against the Zod schema. If any guest has an empty name or invalid email, validation fails and the errors appear inline. The user stays on Step 2 until everything passes.

This per-step validation is a core pattern in multi-step forms. You don't want to validate Step 3 fields while the user is still on Step 2 -- they haven't seen those fields yet. Targeted trigger calls give you exactly this control.

How It Fits Together

The StepTickets component doesn't know it's part of a multi-step form. It doesn't manage navigation, it doesn't own the form instance, and it doesn't call handleSubmit. It just registers its fields and displays its errors. The parent handles everything else -- the FormProvider wrapper, the step state, the navigation buttons, and the per-step validation.

This separation is the payoff from the patterns we built up in earlier lessons. FormProvider distributes the form context. useFormContext gives child components access to register and control. useFieldArray manages the dynamic guest list. trigger validates specific fields on demand. Each piece does one job, and they compose cleanly into a multi-step flow.