React Hook Form 13 ๐ Dynamic Fields โ useFieldArray for repeatable form sections
The useFieldArray hook is the mechanism for the forms that have a list of the repeated field groupsโthe invoice line items, the product variants, the emergency contacts, the team members. The form’s value is an array of the objects, and the user can add a row, remove a row, reorder the rows, and insert a row in the middle. The hook is not the useState with the array. It is the connection between the form’s internal array state and the rendered rows, and it handles the unique keys, the default values, and the validation of the array itself.
The hook’s return value is the fields array and the mutation methods. The fields array is not the form’s values. It is the render metadata: each entry contains the row’s default values and a generated unique key, and the key is the mechanism that keeps the rows stable when the array is reordered or a row is removed from the middle . The form’s actual values are read from the watch or the getValues, and the inputs are registered with the index path like items.0.name. The distinction is the source of the most common bug: mapping the fields with the index as the key instead of the field.id .
Key point: useFieldArray({ control, name }) returns the fields and the methods (append, prepend, insert, remove, swap, move, update, replace). The fields entries have the generated id (or key in v8), and that id is the required React key. The inputs are registered with the index path, and the array’s own validation errors land in errors.name.root or errors.name.message.
Why the useFieldArray hook exists
The array-state problem. A form with a dynamic list needs the array state that is separate from the individual field state. The useState with the array works for the simple case, but it duplicates the logic that the form already has: the registration of the new fields, the removal of the unmounted fields, the validation of the array length, and the error mapping. The useFieldArray is the mechanism that connects the array to the form’s state, and the connection is the reason the array’s values are included in the handleSubmit data without the manual state merging.
The key problem. When the array is reordered or a row is removed from the middle, React needs the stable keys to preserve the component state and the focus. The array index is the unstable key: removing the first row makes the second row’s index 0, and React reuses the first row’s DOM for the second row’s data. The useFieldArray generates the unique id for each row, and the field.id is the stable key . The key is the mechanism that keeps the rows’ identities, and the identity is the reason the input values do not jump between the rows.
The default-value problem. The append requires the complete object with every field’s default value. The append({}) and the append() are the invalid calls, because the new row has no registered inputs, and the form’s values do not include the row . The complete object is the mechanism that registers the new fields, and the registration is the reason the row is editable.
The array-validation problem. The array itself can have the validation rules: the minimum length, the maximum length, the custom check. The useFieldArray accepts the rules prop, and the array’s errors land in errors.name.root (or errors.name.message when the array is empty). The separation is the mechanism that keeps the array’s errors separate from the row’s errors, and the separation is the reason the error display reads the root .
The shouldUnregister problem. The useFieldArray relies on the inputs being mounted and unmounted to manage the internal state. The shouldUnregister: true in the useForm causes the newly added fields to be unregistered on the re-render, and the values are lost . The incompatibility is the mechanism that keeps the field array from the shouldUnregister forms, and the reset is the alternative for the conditional rendering.
a. The basic configuration and the row rendering
The useFieldArray requires the control and the name. The control is the object from the useForm, and the name is the path to the array in the form values.
// The basic useFieldArray
import { useForm, useFieldArray } from "react-hook-form";
type InvoiceFormValues = {
invoiceNumber: string;
lineItems: { description: string; quantity: number; unitPrice: number }[];
};
function InvoiceForm() {
const { register, control, handleSubmit, watch, formState: { errors } } = useForm<InvoiceFormValues>({
defaultValues: {
invoiceNumber: "",
lineItems: [{ description: "", quantity: 1, unitPrice: 0 }],
},
});
// The useFieldArray connects the array to the form.
const { fields, append, remove } = useFieldArray({
control,
name: "lineItems",
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("invoiceNumber")} placeholder="Invoice #" />
{/* The fields is the render metadata. */}
{fields.map((field, index) => (
// The field.id is the stable key, not the index.
<div key={field.id}>
<input
{...register(`lineItems.${index}.description`)}
placeholder="Description"
/>
<input
type="number"
{...register(`lineItems.${index}.quantity`, { valueAsNumber: true })}
/>
<input
type="number"
step="0.01"
{...register(`lineItems.${index}.unitPrice`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
{/* The append requires the complete object. */}
<button
type="button"
onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}
>
Add line item
</button>
</form>
);
}
The register(lineItems.${index}.description) is the mechanism that registers the input with the index path. The index is the row’s position in the form values, and the path is the mechanism that connects the input to the array element. The field.id is the render key, and the index is the data path. The two are the different identifiers, and the confusion between them is the source of the re-render bugs.
The remove(index) is the mechanism that removes the row at the position. The append({ description: "", quantity: 1, unitPrice: 0 }) is the mechanism that adds the complete row to the end. The complete object is required, and the partial object is the invalid call .
b. The array validation and the live total
The useFieldArray accepts the rules prop, and the rules are the same as the register rules: the required, the minLength, the maxLength, the validate. The array’s errors land in the errors.name.root object, and the errors.name.message is the fallback when the array is empty.
// The array validation
const { fields, append, remove } = useFieldArray({
control,
name: "lineItems",
rules: {
// The minimum length is the array rule.
minLength: { value: 1, message: "At least one line item is required." },
// The maximum length is the array rule.
maxLength: { value: 10, message: "At most 10 line items are allowed." },
},
});
// The array error display
{errors.lineItems?.root?.message && (
<p role="alert">{errors.lineItems.root.message}</p>
)}
{errors.lineItems?.message && (
<p role="alert">{errors.lineItems.message}</p>
)}
The errors.lineItems.root.message is the array-level error when the rows exist, and the errors.lineItems.message is the error when the array is empty . The two are the different shapes of the array error, and the fallback is the mechanism that covers the empty case.
The live total is the derived value that reads the array and computes the sum. The watch is the mechanism that subscribes to the array, and the useWatch is the scoped alternative for the child component.
// The live total
const lineItems = watch("lineItems");
const total = lineItems?.reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.unitPrice || 0);
}, 0) ?? 0;
// The total display
<p>Total: ${total.toFixed(2)}</p>
The watch("lineItems") is the subscription that re-renders the form on every change to the array. The useWatch is the alternative that scopes the re-render to the component that reads the total . The choice is the re-render scope, and the scope is the performance mechanism.
The update and the replace are the methods for the bulk changes. The update(index, obj) updates the row at the position and unmounts and remounts the fields, and the replace(newArray) replaces the entire array . The replace is the mechanism for the async data load, and the reset(data) is the alternative.
Complete Example Session
// ============================================
// PART 1: THE BASIC FIELD ARRAY
// ============================================
import { useForm, useFieldArray } from "react-hook-form";
function InvoiceForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: {
lineItems: [{ description: "", quantity: 1, unitPrice: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "lineItems",
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`lineItems.${index}.description`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}>
Add
</button>
</form>
);
}
// ============================================
// PART 2: THE STABLE KEY
// ============================================
// The field.id is the stable key.
// The index is the unstable key.
{fields.map((field, index) => <div key={field.id}>...</div>)}
// ============================================
// PART 3: THE COMPLETE APPEND
// ============================================
// The append requires the complete object.
append({ description: "", quantity: 1, unitPrice: 0 }); // โ
append({ description: "" }); // โ the missing fields
// ============================================
// PART 4: THE ARRAY VALIDATION
// ============================================
const { fields } = useFieldArray({
control,
name: "lineItems",
rules: {
minLength: { value: 1, message: "At least one item." },
maxLength: { value: 10, message: "At most 10 items." },
},
});
// ============================================
// PART 5: THE ARRAY ERROR
// ============================================
{errors.lineItems?.root?.message && <p>{errors.lineItems.root.message}</p>}
{errors.lineItems?.message && <p>{errors.lineItems.message}</p>}
// ============================================
// PART 6: THE LIVE TOTAL
// ============================================
const items = watch("lineItems");
const total = items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
// ============================================
// PART 7: THE UPDATE METHOD
// ============================================
// The update replaces the row and remounts the fields.
update(0, { description: "New", quantity: 2, unitPrice: 10 });
// ============================================
// PART 8: THE REPLACE METHOD
// ============================================
// The replace replaces the entire array.
replace([{ description: "A", quantity: 1, unitPrice: 5 }]);
// ============================================
// PART 9: THE ASYNC DATA
// ============================================
// The reset is the mechanism for the async data.
reset(apiData);
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The field.id is the React key, not the index.
// [ ] The append receives the complete object.
// [ ] The array rules are in the useFieldArray's rules.
// [ ] The array errors are read from the root.
// [ ] The shouldUnregister is not used.
The ten parts covered the basic field array, the stable key, the complete append, the array validation, the array error, the live total, the update method, the replace method, the async data, and the checklist. The through-line is that the fields is the render metadata, and the index is the data path.
Quick Reference
The useFieldArray props
| Prop | Required | Purpose |
|---|---|---|
control | Yes (or the FormProvider) | The form control |
name | Yes | The array path |
rules | No | The array validation |
keyName | No | The key attribute name ("id" default, removed in v8) |
disabled | No | Disable the mutations |
The methods
| Method | Signature | Purpose |
|---|---|---|
append | (obj | obj[]) => void | Add to the end |
prepend | (obj | obj[]) => void | Add to the start |
insert | (index, obj | obj[]) => void | Insert at the position |
remove | (index | index[]) => void | Remove the row or rows |
swap | (from, to) => void | Swap the two rows |
move | (from, to) => void | Move the row |
update | (index, obj) => void | Update and remount the row |
replace | (obj[]) => void | Replace the entire array |
The error shape
| Error | Path |
|---|---|
| The array error with rows | errors.name.root.message |
| The array error when empty | errors.name.message |
| The row error | errors.name[index].field.message |
The v8 changes
| v7 | v8 |
|---|---|
field.id | field.key |
The keyName prop | Removed |
| The flat array not supported | The flat array supported |
Best Practices
โ Do This:
// Use the field.id as the key
{fields.map((field, index) => <div key={field.id}>...</div>)} // โ
// Append the complete object
append({ description: "", quantity: 1, unitPrice: 0 }); // โ
// Read the array error from the root
{errors.items?.root?.message && <p>{errors.items.root.message}</p>} // โ
โ Don’t Do This:
// Use the index as the key
{fields.map((field, index) => <div key={index}>...</div>)} // โ
// Append the partial object
append({ description: "" }); // โ
// Use the shouldUnregister with the field array
useForm({ shouldUnregister: true }); // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The rows mix up on remove | The index is the key | Use the field.id |
| The append does nothing | The shouldUnregister is true | Remove the option |
| The new row is empty | The partial object is appended | Append the complete object |
| The array error is not displayed | The root is not read | Read the errors.name.root |
| The array is empty after the API load | The defaultValues is read once | Use the values prop or the reset |
Real-World Examples
1. The basic map
{fields.map((field, index) => <div key={field.id}>...</div>)}
2. The append
append({ name: "", price: 0 })
3. The remove
remove(index)
4. The insert
insert(1, { name: "", price: 0 })
5. The swap
swap(0, 1)
6. The array rules
rules={{ minLength: { value: 1, message: "Required" } }}
7. The array error
errors.items?.root?.message
8. The live total
watch("items").reduce((sum, i) => sum + i.price, 0)
9. The replace
replace(newItems)
10. The reset
reset(apiData)
Visual
The Fields and the Values
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE FIELDS vs THE VALUES โ
โ โ
โ fields (the render metadata) โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ { id: "abc", ... } โ The stable key โ
โ โ { id: "def", ... } โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ The form values โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ [{ name: "A" }, โ The registered inputs โ
โ โ { name: "B" }] โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Stable Key
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE STABLE KEY โ
โ โ
โ โ The index key: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Remove the first rowโ The second row's index becomes 0 โ
โ โ The second row is โ React reuses the first DOM โ
โ โ now the index 0 โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ
The field.id key: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The second row keepsโ React preserves the identity โ
โ โ its id โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Array Error
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE ARRAY ERROR โ
โ โ
โ The minLength rule fails โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ errors.items.root โ The array-level error โ
โ โ .message โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ The row error is separate: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ errors.items[0] โ The field-level error โ
โ โ .name.message โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| The hook | useFieldArray({ control, name, rules }) |
| The return | The fields and the methods |
| The key | The field.id (the field.key in v8) |
| The append | The complete object |
| The array error | The errors.name.root.message |
The shouldUnregister | Not supported |
| The async data | The values prop or the reset |
Key takeaways:
- The
fieldsis the render metadata, not the form values. Each entry has the default values and the generatedid, and theidis the required React key. The inputs are registered with the index path likeitems.0.name. - The
field.idis the stable key, theindexis the data path. Theindexas the key causes the rows to mix up when the array is reordered or a row is removed from the middle. Thefield.idis the mechanism that preserves the row identities . - The
appendrequires the complete object. Theappend({})and the partial object are the invalid calls, because the new row has no registered inputs. The complete object is the mechanism that registers the new fields . - The array’s validation errors land in the
root. Theerrors.name.root.messageis the array-level error when the rows exist, and theerrors.name.messageis the error when the array is empty. The two are the different shapes, and the fallback is the mechanism that covers both . - The
useFieldArrayis incompatible with theshouldUnregister. The hook relies on the inputs being mounted and unmounted, and theshouldUnregister: truecauses the newly added fields to be unregistered on the re-render. Theresetis the alternative for the conditional rendering .
Remember: The React Hook Form useFieldArray is the hook for the dynamic field groups. The fields is the render metadata with the stable id, and the inputs are registered with the index path. The append requires the complete object, and the array’s errors land in the root. The shouldUnregister is not supported, and the reset is the alternative for the async data. The hook is the connection between the form’s array state and the rendered rows.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!