| | |

React Hook Form 13 ๐Ÿ“‹ Dynamic Fields โ€” useFieldArray for repeatable form sections

โฑ๏ธ Estimated read: 13 min | |

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

PropRequiredPurpose
controlYes (or the FormProvider)The form control
nameYesThe array path
rulesNoThe array validation
keyNameNoThe key attribute name ("id" default, removed in v8)
disabledNoDisable the mutations

The methods

MethodSignaturePurpose
append(obj | obj[]) => voidAdd to the end
prepend(obj | obj[]) => voidAdd to the start
insert(index, obj | obj[]) => voidInsert at the position
remove(index | index[]) => voidRemove the row or rows
swap(from, to) => voidSwap the two rows
move(from, to) => voidMove the row
update(index, obj) => voidUpdate and remount the row
replace(obj[]) => voidReplace the entire array

The error shape

ErrorPath
The array error with rowserrors.name.root.message
The array error when emptyerrors.name.message
The row errorerrors.name[index].field.message

The v8 changes

v7v8
field.idfield.key
The keyName propRemoved
The flat array not supportedThe 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

PitfallWhy It HappensFix
The rows mix up on removeThe index is the keyUse the field.id
The append does nothingThe shouldUnregister is trueRemove the option
The new row is emptyThe partial object is appendedAppend the complete object
The array error is not displayedThe root is not readRead the errors.name.root
The array is empty after the API loadThe defaultValues is read onceUse 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

ItemValue
The hookuseFieldArray({ control, name, rules })
The returnThe fields and the methods
The keyThe field.id (the field.key in v8)
The appendThe complete object
The array errorThe errors.name.root.message
The shouldUnregisterNot supported
The async dataThe values prop or the reset

Key takeaways:

  • The fields is the render metadata, not the form values. Each entry has the default values and the generated id, and the id is the required React key. The inputs are registered with the index path like items.0.name .
  • The field.id is the stable key, the index is the data path. The index as the key causes the rows to mix up when the array is reordered or a row is removed from the middle. The field.id is the mechanism that preserves the row identities .
  • The append requires the complete object. The append({}) 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. The errors.name.root.message is the array-level error when the rows exist, and the errors.name.message is the error when the array is empty. The two are the different shapes, and the fallback is the mechanism that covers both .
  • The useFieldArray is incompatible with the shouldUnregister. The hook relies on the inputs being mounted and unmounted, and the shouldUnregister: true causes the newly added fields to be unregistered on the re-render. The reset is 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.

Was this helpful?


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!