useFieldArray: Dynamic Form Fields with React Hook Form
AI generated
{ }
React 19 · React Hook Form · Forms
useFieldArray in Practice
Managing dynamic form fields without re-rendering the whole list on every keystroke

Address lists, invoice line items, or contact persons: as soon as a form needs a variable number of similar entries, manual array state quickly becomes a performance drag. useFieldArray from React Hook Form solves exactly this problem selectively rather than across the board.

13 min read useFieldArray · React Hook Form Zod Validation

1. The Problem with Manual Array State

A form for several shipping addresses or invoice line items needs a list of entries the user can add to and remove from. The obvious approach with useState holds the entire list in a single state value, and every change to a single field, say a keystroke in a street name input, replaces the whole array with a new copy and thereby triggers a re-render of the entire list, not just the affected row.

With ten or more entries this effect becomes noticeable: every keystroke re-renders all rows, even though only a single character in a single field changed. React Hook Form takes a different approach and works uncontrolled by default, meaning through refs instead of state updates per keystroke. useFieldArray consistently carries this principle over to arrays of fields and keeps re-renders limited to what's structurally necessary.

2. The API at a Glance

useFieldArray({ control, name }) returns an object with the current fields array plus the methods append, remove, insert, update, move and swap. Every element in fields carries, alongside the actual form values, a stable id property generated by React Hook Form that does not change regardless of its position in the array.

That exact id has to be used as the React key for every rendered row, never the array index. Use the index as the key instead, and removing or inserting an entry in the middle of the list can make React assign existing DOM nodes, and thus the internal values of uncontrolled inputs, to the wrong rows, a subtle bug where suddenly the wrong values show up in the wrong fields.


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

function AddressForm() {
  const { control, register, handleSubmit } = useForm({
    defaultValues: { addresses: [{ street: '', city: '' }] },
  });
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'addresses',
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`addresses.${index}.street`)} />
          <input {...register(`addresses.${index}.city`)} />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      <button type="button" onClick={() => append({ street: '', city: '' })}>
        Add address
      </button>
    </form>
  );
}

3. Practical Example: Multiple Shipping Addresses

For an address list, each row registers its own fields via a dynamic path like addresses.${index}.street, where index comes from the position in the fields array. append({ street: '', city: '' }) adds a new empty row, remove(index) removes an entry by its current position, both reactively without triggering a full form reset.

It matters to set the initial entries via defaultValues in the surrounding useForm call, so the first row or several pre-filled rows appear correctly before the user interacts at all. Subsequent changes to the list then run exclusively through the useFieldArray methods, not through direct manipulation of defaultValues.


function AddressList() {
  const { control, register } = useForm({
    defaultValues: {
      addresses: [{ street: 'Main Street 1', city: 'Berlin' }],
    },
  });
  const { fields, append, remove } = useFieldArray({ control, name: 'addresses' });

  return (
    <>
      {fields.map((field, index) => (
        <fieldset key={field.id}>
          <legend>Address {index + 1}</legend>
          <input {...register(`addresses.${index}.street`)} placeholder="Street" />
          <input {...register(`addresses.${index}.city`)} placeholder="City" />
          {fields.length > 1 && (
            <button type="button" onClick={() => remove(index)}>Remove</button>
          )}
        </fieldset>
      ))}
      <button type="button" onClick={() => append({ street: '', city: '' })}>
        Add another address
      </button>
    </>
  );
}

4. Practical Example: Invoice Line Items with Live Totals

Invoice line items typically consist of nested objects with item name, quantity, and unit price. For a live calculation of the row total, a global watch() on the whole form isn't enough, because then a change anywhere in any row triggers recalculation of every row.

useWatch with a targeted name such as items.${index}.quantity observes exclusively that one property of that one row and narrows the re-render surface down to exactly that component. With ten or more line items, the difference between a global watch() and a targeted useWatch per row is clearly noticeable in perceived responsiveness.


function InvoiceRow({ control, index, remove }) {
  const quantity = useWatch({ control, name: `items.${index}.quantity` });
  const price = useWatch({ control, name: `items.${index}.price` });
  const total = (Number(quantity) || 0) * (Number(price) || 0);

  return (
    <div>
      <input type="number" {...control.register(`items.${index}.quantity`)} />
      <input type="number" {...control.register(`items.${index}.price`)} />
      <span>{total.toFixed(2)} EUR</span>
      <button type="button" onClick={() => remove(index)}>Remove</button>
    </div>
  );
}

5. Why This Beats Manual Array State

React Hook Form holds input values primarily in an internal ref structure rather than in React state. A re-render only happens when a component has explicitly subscribed to that exact value via watch, useWatch or formState, every other component stays completely unaffected.

useFieldArray itself only re-renders the parent component on structural changes to the list, that is on append, remove, insert, move or swap. Keystrokes in individual inputs stay locally isolated and don't trigger a re-render of the list, that is the central performance advantage over an array managed with useState, where every keystroke re-renders the entire list.

6. Validation per Field Entry

A schema-based resolver like zodResolver works well for validation, combined with a Zod schema describing an array of objects: z.array(z.object({ street: z.string().min(1), city: z.string().min(1) })). Errors then land in a structurally matching shape under errors.addresses[index].street and can be displayed directly next to the relevant row.

On top of validating individual fields, the array length itself can be checked too, for example with .min(1) on the array schema, to ensure at least one entry exists. Such an array-level error appears separately from the field-level errors of individual rows and needs to be displayed in its own place, usually above the list.


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

const schema = z.object({
  addresses: z.array(
    z.object({
      street: z.string().min(1, 'Street is required'),
      city: z.string().min(1, 'City is required'),
    })
  ).min(1, 'At least one address is required'),
});

function AddressForm() {
  const { control, register, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { addresses: [{ street: '', city: '' }] },
  });
  const { fields } = useFieldArray({ control, name: 'addresses' });

  return fields.map((field, index) => (
    <div key={field.id}>
      <input {...register(`addresses.${index}.street`)} />
      {errors.addresses?.[index]?.street && (
        <span>{errors.addresses[index].street.message}</span>
      )}
    </div>
  ));
}

7. Reordering with move and swap

move(from, to) moves an entry from one position to another, swap(a, b) swaps two positions directly with each other. Both methods work internally through the stable id structure of the field entries, manual array splicing followed by a setValue call isn't necessary and would risk breaking React Hook Form's internal consistency.

Combined with a drag and drop library like dnd-kit, it's usually enough to determine the new target position in the onDragEnd callback and call move(oldIndex, newIndex) there. React Hook Form then takes care of correctly reordering the internal field references, without individual input values getting lost or swapped.


function ReorderableList() {
  const { control } = useForm({ defaultValues: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] } });
  const { fields, move } = useFieldArray({ control, name: 'items' });

  return fields.map((field, index) => (
    <div key={field.id}>
      <span>{field.label}</span>
      <button type="button" disabled={index === 0} onClick={() => move(index, index - 1)}>
        Move up
      </button>
      <button type="button" disabled={index === fields.length - 1} onClick={() => move(index, index + 1)}>
        Move down
      </button>
    </div>
  ));
}

8. Nested Field Arrays

Some forms need lists within lists, for example an invoice line item with its own sub-list of discounts. This requires two coupled useFieldArray calls: an outer one for the invoice items, and an inner one per row for that row's discounts.

The inner array's path has to be built dynamically from the outer index, for example items.${index}.discounts. To keep the inner hook correctly isolated and prevent it from accidentally accessing the wrong row, a dedicated sub-component per row is recommended, one that receives the index as a prop and encapsulates its own useFieldArray call.


function InvoiceItemRow({ control, itemIndex }) {
  const { fields, append, remove } = useFieldArray({
    control,
    name: `items.${itemIndex}.discounts`,
  });

  return (
    <div>
      {fields.map((discount, dIndex) => (
        <div key={discount.id}>
          <input {...control.register(`items.${itemIndex}.discounts.${dIndex}.percent`)} />
          <button type="button" onClick={() => remove(dIndex)}>Remove discount</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ percent: 0 })}>
        Add discount
      </button>
    </div>
  );
}

9. Common Mistakes with useFieldArray

The most common mistake is using the array index as the React key instead of fields[i].id. Removing or inserting in the middle of the list then makes React assign existing DOM nodes to the wrong logical entries, uncontrolled inputs keep their old, now wrong value, even though the underlying data model was updated correctly.

A second common mistake is asynchronously loading defaultValues after an API call without calling reset() afterwards. useFieldArray then stays empty, because the original defaultValues were already locked in on the first render. On top of that, a lack of understanding of shouldUnregister for conditionally rendered rows often leads to values of removed fields unintentionally sticking around in the form state.

Approach Re-render on Field Change Per-entry Validation Code Effort
Manual array state with useState Entire list on every keystroke Written manually per field High, lots of boilerplate
useFieldArray without a resolver Only on append, remove, insert, move, swap Manually in onSubmit or onBlur Medium
useFieldArray with zodResolver Only on structural changes Declarative via schema, including array length Low
useFieldArray with nested arrays Isolated per row via coupled hooks Declarative per level via nested schema Medium to high with deep nesting

Mironsoft

React architecture, performance, and Magento frontend integration

React frontends that stay fast instead of slowing down with every feature?

We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.

Performance Audit

Systematically measuring and fixing re-renders, bundle size, and load times.

State Architecture

Cleanly separating context, client state, and server state instead of mixing everything.

Magento Integration

Building robust, type-safe GraphQL or REST integration with Magento.

10. Summary

useFieldArray with React Hook Form: The Essentials at a Glance

Core Problem

Manual array state re-renders the whole list on every change, useFieldArray only on structural changes.

Stable Keys

fields[i].id instead of the array index as the React key prevents swapped values when removing and inserting.

Validation

zodResolver with z.array(z.object(...)) validates each entry individually and the overall array length.

Reordering

move() and swap() work through the internal id structure, no manual array splicing needed.

11. FAQ: useFieldArray with React Hook Form: The Essentials at a Glance

1What does useFieldArray do differently from an array in useState?
useFieldArray uses React Hook Form's uncontrolled ref architecture and only re-renders the list on structural changes like adding or removing entries. An array in useState replaces the entire array on every field change and therefore always re-renders the whole list.
2Why can't I use the array index as the React key?
Removing or inserting in the middle of the list shifts the indices of all following entries. React then assigns existing DOM nodes and their uncontrolled input values to the wrong logical entries. The stable id generated by React Hook Form in fields[i].id prevents this problem.
3How do I add pre-filled entries on the very first render?
Via defaultValues in the surrounding useForm call, for example defaultValues: { addresses: [{ street: '', city: '' }] }. useFieldArray reads this initial structure and displays it right away, before the user ever calls append or remove.
4How do I validate individual entries of a list?
With a schema resolver like zodResolver and a Zod schema shaped like z.array(z.object({...})). Errors for individual fields then land in a structurally matching shape under errors.fieldName[index].fieldname and can be shown directly next to the relevant row.
5Can I also check whether the list has at least one entry?
Yes, with .min(1) directly on the array schema itself, for example z.array(z.object({...})).min(1). This array-level error is separate from the field-level errors of individual rows and needs to be displayed separately, usually above the list.
6How do I reorder entries with drag and drop?
In the drag and drop library's onDragEnd callback, determine the new target position and call move(oldIndex, newIndex) from useFieldArray there. React Hook Form then automatically takes care of correctly reordering the internal field references.
7How do nested field arrays work, for example discounts per invoice line item?
Through two coupled useFieldArray calls, an outer one for the invoice items and an inner one per row for that row's discounts. The inner array's path is built dynamically from the outer index, ideally encapsulated in a dedicated sub-component per row.
8Why does useFieldArray stay empty after an asynchronous API load?
Because defaultValues are locked in on the very first render. If the actual values only arrive later asynchronously, reset() has to be called afterwards with the new values, otherwise useFieldArray stays stuck on its original empty state.
9What does useWatch do compared to a global watch()?
useWatch with a targeted name observes exactly that one property and only triggers a re-render in the component that calls useWatch. A global watch() without a name observes the entire form and re-renders on any field change.
10What is shouldUnregister and why does it matter for conditional rows?
shouldUnregister determines whether a field's value is removed from the form state once it disappears from the DOM. For conditionally rendered rows, not understanding this option can leave values of long-removed fields unintentionally in the form state and have them submitted later.