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.
Table of Contents
- 1. The Problem with Manual Array State
- 2. The API at a Glance
- 3. Practical Example: Multiple Shipping Addresses
- 4. Practical Example: Invoice Line Items with Live Totals
- 5. Why This Beats Manual Array State
- 6. Validation per Field Entry
- 7. Reordering with move and swap
- 8. Nested Field Arrays
- 9. Common Mistakes with useFieldArray
- 10. Summary
- 11. FAQ
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.