Dynamic Fields with useFieldArray
Add and remove array items at runtime, validate each row independently, and submit the full typed array to your server action.
Why it matters
Dynamic lists — invoice line items, contact addresses, team members, skill tags — are one of the most common patterns in real-world forms. React Hook Form's useFieldArray is the standard solution, but integrating it with useActionForm has nuances: the control object must come from the hook, arrays don't play well with FormData actions, and per-item error paths have a specific shape.
This recipe shows a complete address list form with add, remove, per-field errors, and a withZod server action that receives and validates the full typed array.
Full Example — Address List
'use server'
import { z } from 'zod'
import { withZod } from 'hookform-action-core/with-zod'
const addressSchema = z.object({
addresses: z
.array(
z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
country: z.string().min(2, 'Select a country'),
})
)
.min(1, 'Add at least one address'),
})
export const saveAddressesAction = withZod(addressSchema, async (data) => {
// data.addresses is typed as { street: string; city: string; country: string }[]
await db.addresses.replaceAll(data.addresses)
return { success: true }
})'use client'
import { useFieldArray } from 'react-hook-form' // from RHF, not hookform-action
import { useActionForm } from 'hookform-action'
import { saveAddressesAction } from './actions'
type AddressValues = {
addresses: { street: string; city: string; country: string }[]
}
const EMPTY_ADDRESS = { street: '', city: '', country: '' }
export function AddressForm() {
const {
register,
control, // ← pass to useFieldArray
handleSubmit,
formState: { errors, isPending, isSubmitSuccessful },
} = useActionForm<AddressValues>(saveAddressesAction, {
defaultValues: { addresses: [EMPTY_ADDRESS] },
})
// useFieldArray reads and writes through the same RHF control
const { fields, append, remove, move } = useFieldArray({
control,
name: 'addresses',
})
if (isSubmitSuccessful) {
return <p className="text-green-400">Addresses saved!</p>
}
return (
<form onSubmit={handleSubmit()}>
<div className="space-y-4">
{fields.map((field, index) => (
// IMPORTANT: use field.id as key — not the array index
<div key={field.id} className="border border-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-400">
Address {index + 1}
</span>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
)}
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Street</label>
<input
{...register(`addresses.${index}.street`)}
placeholder="123 Main St"
/>
{errors.addresses?.[index]?.street && (
<p className="text-red-400 text-sm mt-1">
{errors.addresses[index].street?.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500">City</label>
<input
{...register(`addresses.${index}.city`)}
placeholder="New York"
/>
{errors.addresses?.[index]?.city && (
<p className="text-red-400 text-sm mt-1">
{errors.addresses[index].city?.message}
</p>
)}
</div>
<div>
<label className="text-xs text-gray-500">Country</label>
<select {...register(`addresses.${index}.country`)}>
<option value="">Select…</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="BR">Brazil</option>
</select>
{errors.addresses?.[index]?.country && (
<p className="text-red-400 text-sm mt-1">
{errors.addresses[index].country?.message}
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
{/* Array-level error (e.g. "Add at least one address") */}
{errors.addresses?.root && (
<p className="text-red-400 text-sm mt-2">{errors.addresses.root.message}</p>
)}
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={() => append(EMPTY_ADDRESS)}
className="text-sm text-brand-400 hover:text-brand-300"
>
+ Add address
</button>
<button type="submit" disabled={isPending} className="ml-auto">
{isPending ? 'Saving…' : 'Save Addresses'}
</button>
</div>
</form>
)
}Key Concepts
useFieldArray requires controluseFieldArray is from react-hook-form (not this library). It needs the control object from useActionForm. You cannot use it with the standalone register spread pattern.
field.id as key (not index)useFieldArray generates stable IDs for each row. Always use field.id as the React key, not the array index. Index-based keys cause incorrect animations and state mismatches when rows are removed.
register(`name.${index}.field`) — dot notationUse template literal dot-notation to register nested array fields. RHF will collect them into a properly structured array in getValues() and on submit.
Use withZod (JSON action), not FormDataFormData doesn't natively support nested arrays. Always use withZod (which sends JSON) for forms with useFieldArray. If you must use a FormData action, serialize the array manually with JSON.stringify.
⚠️ Pitfalls
- ⚠
Using the array index as
keyWhen you remove item at index 1 from [0, 1, 2], React reuses the DOM node for the new index 1 (previously index 2). Input values get mixed up. Always use
field.id. - ⚠
Per-item errors path typo
The errors path is
errors.addresses?.[index]?.street?.message— note the optional chaining at each level. Missing any level causes a runtime error when there are no errors. - ⚠
EMPTY_ADDRESS defined inside the component
Define the empty item template outside the component (or with
useCallback/useMemo). An inline object creates a new reference on every render and can cause unexpectedappendbehaviour.