Lesson 12

Dynamic Fields with useFieldArray

Some forms have a fixed set of fields. Others don't -- the user decides how many there are. Invoice line items, shipping addresses, phone numbers, team member lists. Whenever your form data contains an array of objects, you need useFieldArray. It manages repeatable field groups inside React Hook Form, handling identity tracking, re-renders, and validation automatically -- no manual useState wiring required.

Setting Up useFieldArray

Import the hook alongside useForm and pass it the form's control object plus the name of the array field:

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

function InvoiceForm() {
  const { control, register, handleSubmit } = useForm({
    defaultValues: {
      items: [{ name: "", price: 0 }],
    },
  });

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

The control object connects the field array to the parent form. The name tells RHF which property in your form data holds the array. The hook returns fields (the current array) plus methods for manipulating it: append, remove, prepend, insert, move, and swap. You'll use append and remove constantly. The others are there for drag-and-drop reordering or inserting at a specific position.

Rendering the Field List

Loop over fields with .map() to render each group of inputs. The critical rule: use field.id as the key, not the array index.

{fields.map((field, index) => (
  <div key={field.id}>
    <input {...register(`items.${index}.name`)} placeholder="Item name" />
    <input
      type="number"
      {...register(`items.${index}.price`, { valueAsNumber: true })}
      placeholder="Price"
    />
    <button type="button" onClick={() => remove(index)}>
      Remove
    </button>
  </div>
))}

Why field.id instead of index? React Hook Form generates a stable unique ID for each field group. When you remove item 2 from a list of 5, the items below it shift up -- their indices change, but their field.id values don't. If you use index as the key, React will re-use DOM nodes incorrectly, and inputs will display stale values from the wrong items. The field.id ensures React matches each field group to the correct DOM nodes across additions, removals, and reorders.

Notice the template literal syntax for field paths: `items.${index}.name`. This tells RHF exactly where each value lives in the form data structure. Each register call uses the index to target the right slot in the array, and RHF resolves these dot-notation paths into a clean nested object automatically.

To add a new row, call append with a default value object that matches the shape of your items -- append({ name: "", price: 0 }). To delete a row, call remove(index). The argument to append sets the default values for the new fields.

Validation on Array Items

Validation works the same as any other field. You can attach rules inline with register, or -- better -- define a Zod schema that validates the entire array including its items:

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const formSchema = z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1, "Name is required"),
        price: z.coerce.number().min(0, "Price must be 0 or more"),
      })
    )
    .min(1, "Add at least one item"),
});

type FormValues = z.infer<typeof formSchema>;

The .min(1, "Add at least one item") on the array itself prevents submission when all items have been removed. Zod's .coerce.number() handles the string-to-number conversion from the input element. Errors on individual items land at errors.items?.[index]?.name, while the array-level error lands at errors.items?.root?.message.

Complete Example

Here's a full invoice-style form with dynamic line items, add/remove functionality, and Zod validation:

import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const formSchema = z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1, "Name is required"),
        price: z.coerce.number().min(0, "Price must be 0 or more"),
      })
    )
    .min(1, "Add at least one item"),
});

type FormValues = z.infer<typeof formSchema>;

function InvoiceForm() {
  const {
    control,
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      items: [{ name: "", price: 0 }],
    },
  });

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

  const onSubmit = (data: FormValues) => {
    console.log("Invoice items:", data.items);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id} style={{ display: "flex", gap: "8px", marginBottom: "8px" }}>
          <div>
            <input {...register(`items.${index}.name`)} placeholder="Item name" />
            {errors.items?.[index]?.name && (
              <p style={{ color: "red" }}>{errors.items[index].name.message}</p>
            )}
          </div>
          <div>
            <input type="number" step="0.01" {...register(`items.${index}.price`)} placeholder="Price" />
            {errors.items?.[index]?.price && (
              <p style={{ color: "red" }}>{errors.items[index].price.message}</p>
            )}
          </div>
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}

      {errors.items?.root && (
        <p style={{ color: "red" }}>{errors.items.root.message}</p>
      )}

      <button type="button" onClick={() => append({ name: "", price: 0 })}>Add Item</button>
      <button type="submit">Submit Invoice</button>
    </form>
  );
}

export default InvoiceForm;

The form starts with one empty row. The user adds more with "Add Item", removes any row with "Remove", and submits when every item has a name and valid price. If they remove all items, the array-level validation catches it. Each field-level error displays directly below its input. The key pieces: useFieldArray manages the array state, field.id keeps React's reconciliation correct, template literal paths connect each input to the right slot, and the Zod schema validates both individual items and the array as a whole.