Skip to main content

Array Fields Guide

Array fields handle dynamic lists — line items, multiple emails, a set of contacts — where the user can add and remove rows.

Try it live

Edit this example in the interactive Playground.

useFieldArray is the cleanest way to build dynamic arrays. It gives each row a stable id to use as the React key, plus operations for reordering and inserting — append, prepend, insert, remove, move, swap, update, and replace.

import {
useForm,
FormProvider,
useFormContext,
useFieldArray,
} from "el-form-react-hooks";

type Form = { items: { name: string; quantity: number }[] };

function ItemsForm() {
const form = useForm<Form>({
defaultValues: { items: [{ name: "", quantity: 1 }] },
});

return (
<FormProvider form={form}>
<Items />
</FormProvider>
);
}

function Items() {
const { register } = useFormContext<Form>().form;
const { fields, append, remove, move } = useFieldArray<Form, "items">({
name: "items",
});

return (
<>
{fields.map((field, index) => (
<div key={field.id}>
{/* ← stable id, never the array index */}
<input {...register(`items.${index}.name`)} placeholder="Name" />
<input
type="number"
{...register(`items.${index}.quantity`)}
placeholder="Qty"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
<button type="button" onClick={() => move(index, index - 1)}>
Move up
</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", quantity: 1 })}>
Add item
</button>
</>
);
}

Why field.id and not the index? Using the array index as a React key breaks the moment you insert, reorder, or remove from the middle of the list — React reuses the wrong DOM node, so input focus and values jump to the wrong row. useFieldArray gives each row a stable identity that survives those operations.

useFieldArray works inside <FormProvider> (where it re-renders only when its array changes) or with a form prop passed directly: useFieldArray({ name: "items", form }). For arrays of scalars (string[]), each field is { id, value }.

If your items already have their own id field, use field.id only as the React key — read the domain id from the form value, since the generated key shadows it in fields.

Low-level helpers: addArrayItem / removeArrayItem

useForm also returns two low-level helpers for mutating an array field by path. These are fine for simple append/remove lists, but for anything involving insert, reorder, or remove-from-the-middle, prefer useFieldArray above — it provides the stable row keys those operations require. Register each element by its dot-path (items.<index>.<key>):

import { useForm } from "el-form-react-hooks";

type LineItem = { name: string; quantity: number };

export function ItemsForm() {
const { register, watch, handleSubmit, addArrayItem, removeArrayItem } =
useForm({
defaultValues: { items: [{ name: "", quantity: 1 }] as LineItem[] },
});

const items = watch("items") || [];

return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{/* key={index} is acceptable only for append-only lists; for insert/reorder
use useFieldArray and key by field.id (see above). */}
{items.map((_, index) => (
<div key={index}>
<input {...register(`items.${index}.name`)} placeholder="Name" />
<input
type="number"
{...register(`items.${index}.quantity`)}
placeholder="Qty"
/>
<button type="button" onClick={() => removeArrayItem("items", index)}>
Remove
</button>
</div>
))}

<button
type="button"
onClick={() => addArrayItem("items", { name: "", quantity: 1 })}
>
Add item
</button>
<button type="submit">Submit</button>
</form>
);
}
  • addArrayItem(path, item) — append item to the array at path.
  • removeArrayItem(path, index) — remove the element at index.

Both update form state and dirty tracking for you.

Arrays of scalars

The same helpers work for plain string/number arrays — append an empty value:

const { register, watch, addArrayItem, removeArrayItem } = useForm({
defaultValues: { tags: [""] as string[] },
});

const tags = watch("tags") || [];

// tags.map((_, i) => <input key={i} {...register(`tags.${i}`)} />)
addArrayItem("tags", ""); // add a row
removeArrayItem("tags", 0); // remove a row

Nested arrays

Paths can point into nested arrays — combine the parent index into the path:

// add a skill to the first team member
addArrayItem("team.0.skills", { name: "", level: 1 });

// remove the 2nd skill from the first team member
removeArrayItem("team.0.skills", 1);

Alternative: manual updates with setValue

If you'd rather manage the array yourself, read it with watch and write it with setValue:

const { watch, setValue } = useForm({
defaultValues: { items: [{ name: "", quantity: 1 }] },
});

const items = watch("items") || [];

const addItem = () => setValue("items", [...items, { name: "", quantity: 1 }]);
const removeItem = (index: number) =>
setValue("items", items.filter((_, i) => i !== index));

Validating arrays with a schema

A Zod array schema validates the whole list, including per-row rules:

import { z } from "zod";

const schema = z.object({
items: z
.array(
z.object({
name: z.string().min(1, "Name is required"),
quantity: z.number().min(1, "Must be at least 1"),
})
)
.min(1, "Add at least one item"),
});

const form = useForm({
validators: { onChange: schema },
defaultValues: { items: [{ name: "", quantity: 1 }] },
});

Per-row errors land under the element path (e.g. items.0.name), so you can render them next to each input via formState.errors.