Skip to content

useFieldArray

Manage dynamic field arrays with optimized performance.

</> useFieldArray: UseFieldArrayProps

Custom hook for working with Field Arrays (dynamic forms). The motivation is to provide a better user experience and performance. You can watch this short video to visualize the performance enhancement.

Props


NameTypeRequiredDescription
namestringName of the field array. Note: Dynamic names are not supported.
controlObjectcontrol object provided by useForm. It's optional if you are using FormProvider.
shouldUnregisterbooleanWhether the Field Array will be unregistered after unmounting.
disabledbooleanSince v7.79.0 Disables the entire field array. When true, fields remains populated but every entry's disabled property is set to true (see Return), all mutation methods (append, prepend, insert, remove, swap, move, update, replace) become no-ops, and the array's internal subscriptions are not set up. Useful for conditionally enabling a field array in discriminated union form shapes.
keyNamestring = "id"Name of the attribute with autogenerated identifier to use as the key prop. If your field objects already contain an id property (e.g. database records), it will be overwritten by this auto-generated identifier unless you set a custom keyName. This prop is no longer required and will be removed in the next major version.
rulesObjectSince v7.34.0 The same validation rules API as for register, which includes: required, minLength, maxLength, validate. In the case of a validation error, the root property is appended to formState.errors?.<name>?.root (e.g. formState.errors?.test?.root for a field array named test), of type FieldError. Important: This is only applicable to built-in validation.

Props example

function FieldArray() {
const { control, register } = useForm()
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray(
{
control, // control props comes from useForm (optional: if you are using FormProvider)
name: "test", // unique name for your Field Array
}
)
return (
<>
{fields.map((field, index) => (
<input
key={field.id} // important to include key with field's id
{...register(`test.${index}.value`)}
/>
))}
</>
)
}

Return


NameTypeDescription
fields(object & { id: string })[]An array whose entries contain the defaultValue and key for each row in your component. Since v7.80.0 When the hook-level disabled prop is set, every entry's disabled property reflects it — but this is not automatically forwarded to the registered input; you need to spread it onto the input yourself (see Rules).
append(obj: object | object[], focusOptions) => voidAppend an input or inputs to the end of your fields and focus them. The input value will be registered during this action. Important: append data is required and cannot be partial. Since v7.0.0 focusOptions accepts { shouldFocus?: boolean = true, focusIndex?: number, focusName?: string }.
prepend(obj: object | object[], focusOptions) => voidPrepend an input or inputs to the start of your fields and focus them. The input value will be registered during this action. Important: prepend data is required and cannot be partial. Since v7.0.0 focusOptions accepts { shouldFocus?: boolean = true, focusIndex?: number, focusName?: string }.
insert(index: number, value: object | object[], focusOptions) => voidInsert an input or inputs at a particular position and focus them. Important: insert data is required and cannot be partial. Since v7.0.0 focusOptions accepts { shouldFocus?: boolean = true, focusIndex?: number, focusName?: string }.
swap(from: number, to: number) => voidSwap the positions of inputs.
move(from: number, to: number) => voidMove an input or inputs to another position.
update(index: number, obj: object) => voidSince v7.11.0 Update an input or inputs at a particular position; updated fields will be unmounted and remounted. If this is not the desired behavior, please use the setValue API instead. Important: update data is required and cannot be partial.
replace(obj: object[]) => voidSince v7.15.0 Replace the entire field array's values.
remove(index?: number | number[]) => voidRemove an input or inputs at a particular position, or remove all if no index is provided.

Rules


  • useFieldArray automatically generates a unique identifier named id which is used for the key prop. For more information on why this is required: https://react.dev/learn/rendering-lists

    The field.id (and not index) must be added as the component key to prevent re-renders breaking the fields:

    // ✅ correct:
    {fields.map((field, index) => <input key={field.id} ... />)}
    // ❌ incorrect:
    {fields.map((field, index) => <input key={index} ... />)}
  • It's recommended to not stack actions one after another.

    // ❌ avoid stacking actions in the same handler
    onClick={() => {
    append({ test: 'test' });
    remove(0);
    }}
    // ✅ Better solution: the remove action happens after the second render
    useEffect(() => {
    remove(0);
    }, [remove])
    onClick={() => {
    append({ test: 'test' });
    }}
  • Each useFieldArray is unique and has its own state update, which means you should not have multiple useFieldArray instances with the same name.

  • Each input name needs to be unique. If you need to build a checkbox or radio button with the same name, use it with useController or Controller.

  • Does not support flat field arrays — each entry in a field array must be an object, not a primitive. { test: [{ value: 'a' }] } ✅ vs { test: ['a', 'b'] } ❌.

  • shouldUnregister: true is not supported. Field array relies on inputs being mounted and unmounted to manage its internal state — enabling shouldUnregister causes newly added fields to be unregistered on re-render, so their values are lost. Avoid combining useFieldArray with shouldUnregister: true.

  • There is no per-entry disabled option — the disabled flag on each object in fields (see Return) simply mirrors the hook-level disabled prop, applied uniformly to every entry. It is not read from data you pass to append/prepend/insert, and it is not automatically forwarded to the registered input — you still need to wire it up yourself:

    const { fields, append } = useFieldArray({
    control,
    name: "test",
    disabled: true,
    })
    {
    fields.map((field, index) => (
    <input
    key={field.id}
    disabled={field.disabled}
    {...register(`test.${index}.value`)}
    />
    ))
    }
  • When you append, prepend, insert and update the field array, the object cannot be an empty object {}; rather, you need to supply all your inputs' defaultValues.

    append() // ❌
    append({}) // ❌
    append({ firstName: "bill", lastName: "luo" }) // ✅

TypeScript


  • When registering an input name, you will have to cast them as const:

    <input key={field.id} {...register(`test.${index}.test` as const)} />
  • Circular references are not supported. Refer to this GitHub issue for more details.

  • For nested field arrays, you will have to cast the field array by its name:

    const { fields } = useFieldArray({ name: `test.${index}.keyValue` as 'test.0.keyValue' });

Examples


import { useForm, useFieldArray } from "react-hook-form"
function App() {
const { register, control, handleSubmit, reset, trigger, setError } = useForm(
{
// defaultValues: {}; you can populate the fields by this attribute
}
)
const { fields, append, remove } = useFieldArray({
control,
name: "test",
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<ul>
{fields.map((item, index) => (
<li key={item.id}>
<input {...register(`test.${index}.firstName`)} />
<Controller
render={({ field }) => <input {...field} />}
name={`test.${index}.lastName`}
control={control}
/>
<button type="button" onClick={() => remove(index)}>
Delete
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => append({ firstName: "bill", lastName: "luo" })}
>
append
</button>
<input type="submit" />
</form>
)
}
import { useForm, useFieldArray, useWatch, Control } from "react-hook-form"
type FormValues = {
cart: {
name: string
price: number
quantity: number
}[]
}
const Total = ({ control }: { control: Control<FormValues> }) => {
const formValues = useWatch({
name: "cart",
control,
})
const total = formValues.reduce(
(acc, current) => acc + (current.price || 0) * (current.quantity || 0),
0
)
return <p>Total Amount: {total}</p>
}
export default function App() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
cart: [{ name: "test", quantity: 1, price: 23 }],
},
mode: "onBlur",
})
const { fields, append, remove } = useFieldArray({
name: "cart",
control,
})
const onSubmit = (data: FormValues) => console.log(data)
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => {
return (
<div key={field.id}>
<section className={"section"} key={field.id}>
<input
placeholder="name"
{...register(`cart.${index}.name` as const, {
required: true,
})}
className={errors?.cart?.[index]?.name ? "error" : ""}
/>
<input
placeholder="quantity"
type="number"
{...register(`cart.${index}.quantity` as const, {
valueAsNumber: true,
required: true,
})}
className={errors?.cart?.[index]?.quantity ? "error" : ""}
/>
<input
placeholder="value"
type="number"
{...register(`cart.${index}.price` as const, {
valueAsNumber: true,
required: true,
})}
className={errors?.cart?.[index]?.price ? "error" : ""}
/>
<button type="button" onClick={() => remove(index)}>
DELETE
</button>
</section>
</div>
)
})}
<Total control={control} />
<button
type="button"
onClick={() =>
append({
name: "",
quantity: 0,
price: 0,
})
}
>
APPEND
</button>
<input type="submit" />
</form>
</div>
)
}
import { useForm, useFieldArray, useWatch } from "react-hook-form"
export default function App() {
const { control, handleSubmit } = useForm()
const { fields, append, update } = useFieldArray({
control,
name: "array",
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<Edit
key={field.id}
control={control}
update={update}
index={index}
value={field}
/>
))}
<button
type="button"
onClick={() => {
append({ firstName: "" })
}}
>
append
</button>
<input type="submit" />
</form>
)
}
const Display = ({ control, index }) => {
const data = useWatch({
control,
name: `array.${index}`,
})
return <p>{data?.firstName}</p>
}
const Edit = ({ update, index, value, control }) => {
const { register, handleSubmit } = useForm({
defaultValues: value,
})
return (
<div>
<Display control={control} index={index} />
<input
placeholder="first name"
{...register(`firstName`, { required: true })}
/>
<button
type="button"
onClick={handleSubmit((data) => update(index, data))}
>
Submit
</button>
</div>
)
}
import { useForm, useWatch, useFieldArray, Control } from "react-hook-form"
const ConditionField = ({ control, index, register }) => {
const output = useWatch({
name: "data",
control,
defaultValue: "yay! I am watching you :)",
})
return (
<>
{output[index]?.name === "bill" && (
<input {...register(`data[${index}].conditional`)} />
)}
<input
{...register(`data[${index}].easyConditional`)}
style={{ display: output[index]?.name === "bill" ? "block" : "none" }}
/>
</>
)
}
const UseFieldArrayUnregister = () => {
const { control, handleSubmit, register } = useForm({
defaultValues: {
data: [{ name: "test" }, { name: "test1" }, { name: "test2" }],
},
mode: "onSubmit",
shouldUnregister: false,
})
const { fields } = useFieldArray({
control,
name: "data",
})
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((data, index) => (
<>
<input {...register(`data[${index}].name`)} />
<ConditionField control={control} register={register} index={index} />
</>
))}
<input type="submit" />
</form>
)
}
import { useForm, useFieldArray } from "react-hook-form"
const App = () => {
const { register, control } = useForm({
defaultValues: {
test: [{ value: "1" }, { value: "2" }],
},
})
const { fields, prepend, append } = useFieldArray({
name: "test",
control,
})
return (
<form>
{fields.map((field, i) => (
<input key={field.id} {...register(`test.${i}.value` as const)} />
))}
<button
type="button"
onClick={() => prepend({ value: "" }, { focusIndex: 1 })}
>
prepend
</button>
<button
type="button"
onClick={() => append({ value: "" }, { focusName: "test.0.value" })}
>
append
</button>
</form>
)
}

Video


Tips


Custom Register

You can also register inputs at Controller without the actual input. This makes useFieldArray quick and flexible to use with complex data structures or the the actual data is not stored inside an input.

import { useForm, useFieldArray, Controller, useWatch } from "react-hook-form"
const ConditionalInput = ({ control, index, field }) => {
const value = useWatch({
name: "test",
control,
})
return (
<Controller
control={control}
name={`test.${index}.firstName`}
render={({ field }) =>
value?.[index]?.checkbox === "on" ? <input {...field} /> : null
}
/>
)
}
function App() {
const { control, register } = useForm()
const { fields, append, prepend } = useFieldArray({
control,
name: "test",
})
return (
<form>
{fields.map((field, index) => (
<ConditionalInput key={field.id} {...{ control, index, field }} />
))}
</form>
)
}

Controlled Field Array

There will be cases where you want to control the entire field array, which means each onChange reflects on the fields object.

import { useForm, useFieldArray } from "react-hook-form"
export default function App() {
const { register, handleSubmit, control, watch } = useForm()
const { fields, append } = useFieldArray({
control,
name: "fieldArray",
})
const watchFieldArray = watch("fieldArray")
const controlledFields = fields.map((field, index) => {
return {
...field,
...watchFieldArray[index],
}
})
return (
<form>
{controlledFields.map((field, index) => {
return (
<input
key={field.id}
{...register(`fieldArray.${index}.name` as const)}
/>
)
})}
</form>
)
}

Thank you for your support

If you find React Hook Form to be useful in your project, please consider starring and supporting it.

Edit