Пошук уроків, статей та іншого контенту
Додавайте, видаляйте й редагуйте повторювані групи полів за допомогою масивів і React Hook Form.
Динамічні поля — це повторювані групи полів, кількість яких користувач може змінювати під час заповнення форми.
Наприклад:
список контактів;
товари в замовленні;
учасники команди;
адреси доставки;
елементи рахунку.
У React Hook Form для роботи з такими структурами використовується хук useFieldArray.
Він надає методи для:
додавання елементів через append;
видалення через remove;
заміни всього масиву через replace;
переміщення елементів через move;
вставки та перестановки елементів;
синхронізації масиву з даними форми.
useFieldArrayТипова форма з масивом полів виглядає так:
const { register, control } = useForm<FormValues>({
defaultValues: {
contacts: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "contacts",
});fields містить поточні елементи масиву. Для кожного елемента React Hook Form створює стабільний ідентифікатор id.
Саме цей ідентифікатор потрібно використовувати як key:
{fields.map((field, index) => (
<div key={field.id}>
{/* поля елемента */}
</div>
))}Не варто використовувати index як key. Після видалення елемента індекси змінюються, і React може повторно використати DOM-вузол не для того елемента. Це часто призводить до неправильного фокусу, значень або стану полів.
Спочатку потрібно описати один елемент масиву та всю форму:
type Contact = {
name: string;
email: string;
role: string;
};
type FormValues = {
contacts: Contact[];
};Назва, передана в useFieldArray, повинна відповідати масиву у структурі форми:
const { fields, append, remove } = useFieldArray({
control,
name: "contacts",
});Для окремих полів використовується шлях із індексом:
register(`contacts.${index}.name`)
register(`contacts.${index}.email`)
register(`contacts.${index}.role`)Індекс потрібен лише для формування імені поля. Для React-ключа використовується field.id.
Нижче наведено форму для редагування списку контактів команди. Користувач може додавати, видаляти та редагувати контакти.
import { useForm, useFieldArray } from "react-hook-form";
type Contact = {
name: string;
email: string;
role: string;
};
type FormValues = {
contacts: Contact[];
};
const emptyContact: Contact = {
name: "",
email: "",
role: "",
};
export default function TeamContactsForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
mode: "onBlur",
defaultValues: {
contacts: [
{
name: "Олена Коваль",
email: "olena@example.com",
role: "Frontend Developer",
},
],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "contacts",
rules: {
minLength: {
value: 1,
message: "Додайте щонайменше один контакт",
},
},
});
const onSubmit = (data: FormValues) => {
console.log("Дані форми:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Контакти команди</h2>
{fields.map((field, index) => {
const contactErrors = errors.contacts?.[index];
return (
<fieldset key={field.id}>
<legend>Контакт {index + 1}</legend>
<div>
<label htmlFor={`contacts-${index}-name`}>Імʼя</label>
<input
id={`contacts-${index}-name`}
{...register(`contacts.${index}.name`, {
required: "Вкажіть імʼя",
minLength: {
value: 2,
message: "Імʼя має містити щонайменше 2 символи",
},
})}
/>
{contactErrors?.name?.message && (
<p role="alert">{contactErrors.name.message}</p>
)}
</div>
<div>
<label htmlFor={`contacts-${index}-email`}>
Електронна пошта
</label>
<input
id={`contacts-${index}-email`}
type="email"
{...register(`contacts.${index}.email`, {
required: "Вкажіть електронну пошту",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Введіть коректну електронну пошту",
},
})}
/>
{contactErrors?.email?.message && (
<p role="alert">{contactErrors.email.message}</p>
)}
</div>
<div>
<label htmlFor={`contacts-${index}-role`}>Роль</label>
<input
id={`contacts-${index}-role`}
{...register(`contacts.${index}.role`, {
required: "Вкажіть роль",
})}
/>
{contactErrors?.role?.message && (
<p role="alert">{contactErrors.role.message}</p>
)}
</div>
<button type="button" onClick={() => remove(index)}>
Видалити контакт
</button>
</fieldset>
);
})}
{errors.contacts?.root?.message && (
<p role="alert">{errors.contacts.root.message}</p>
)}
<button
type="button"
onClick={() => append({ ...emptyContact })}
>
Додати контакт
</button>
<button type="submit">Зберегти</button>
</form>
);
}Після відправлення форма матиме таку структуру:
{
"contacts": [
{
"name": "Олена Коваль",
"email": "olena@example.com",
"role": "Frontend Developer"
},
{
"name": "Іван Петренко",
"email": "ivan@example.com",
"role": "Backend Developer"
}
]
}Метод append додає один або кілька елементів у кінець масиву:
append({
name: "",
email: "",
role: "",
});Можна додати одразу кілька елементів:
append([
{
name: "",
email: "",
role: "",
},
{
name: "",
email: "",
role: "",
},
]);Передавайте повний обʼєкт елемента. Частково заповнені обʼєкти ускладнюють передбачення структури форми та можуть призвести до undefined у полях.
Добре використовувати фабрику або константу для порожнього елемента:
const createEmptyContact = (): Contact => ({
name: "",
email: "",
role: "",
});
append(createEmptyContact());Якщо обʼєкт містить вкладені масиви або обʼєкти, створюйте їх заново для кожного елемента. Не використовуйте один і той самий змінюваний обʼєкт повторно.
Для видалення використовується індекс:
<button type="button" onClick={() => remove(index)}>
Видалити
</button>Також можна видалити кілька елементів:
remove([1, 3]);Якщо потрібно видалити конкретний елемент за його ідентифікатором, спочатку знайдіть його індекс у fields:
const index = fields.findIndex((field) => field.id === targetId);
if (index !== -1) {
remove(index);
}Зазвичай у компонентах списку достатньо передавати index із map.
Редагування вже відбувається автоматично через зареєстровані поля:
<input {...register(`contacts.${index}.name`)} />Коли користувач змінює значення, React Hook Form оновлює відповідний шлях у даних форми.
Не потрібно вручну створювати стан для кожного рядка:
const [contacts, setContacts] = useState([]);Змішування локального стану з useFieldArray часто призводить до розсинхронізації даних. Якщо значення належать формі, краще зберігати їх у React Hook Form.
Для повної заміни одного елемента можна використовувати update:
const { fields, update } = useFieldArray({
control,
name: "contacts",
});
update(index, {
name: "Нове імʼя",
email: "new@example.com",
role: "Designer",
});update замінює весь обʼєкт за індексом. Тому потрібно передати всі його властивості.
Важлива особливість: update може перемонтувати відповідний рядок. Це може вплинути на фокус або локальний стан дочірніх компонентів.
Якщо потрібно змінити лише одне поле та зберегти поточний рядок, використовуйте setValue:
const { register, control, setValue } = useForm<FormValues>();
const { fields } = useFieldArray({
control,
name: "contacts",
});
setValue(`contacts.${index}.role`, "Tech Lead", {
shouldDirty: true,
shouldValidate: true,
});Для масових змін усіх елементів зручно використовувати replace:
replace([
{
name: "Олена Коваль",
email: "olena@example.com",
role: "Frontend Developer",
},
{
name: "Іван Петренко",
email: "ivan@example.com",
role: "Backend Developer",
},
]);Якщо дані завантажуються після першого рендеру, використовуйте reset:
import { useEffect } from "react";
import { useForm, useFieldArray } from "react-hook-form";
function ContactsEditor() {
const { control, register, reset } = useForm<FormValues>({
defaultValues: {
contacts: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "contacts",
});
useEffect(() => {
const serverContacts: Contact[] = [
{
name: "Олена Коваль",
email: "olena@example.com",
role: "Frontend Developer",
},
];
reset({
contacts: serverContacts,
});
}, [reset]);
return (
<div>
{fields.map((field, index) => (
<input
key={field.id}
{...register(`contacts.${index}.name`)}
/>
))}
<button type="button" onClick={() => append({ ...emptyContact })}>
Додати
</button>
<button type="button" onClick={() => remove(0)}>
Видалити перший
</button>
</div>
);
}defaultValues використовуються під час ініціалізації форми. Якщо дані зʼявляються пізніше, простого оновлення defaultValues недостатньо — потрібно викликати reset або replace.
Використовуйте:
reset, якщо потрібно синхронізувати всю форму;
replace, якщо потрібно замінити лише масив;
append, якщо потрібно додати нові елементи до наявних.
Правила валідації передаються в register так само, як і для звичайних полів:
<input
{...register(`contacts.${index}.email`, {
required: "Електронна пошта є обовʼязковою",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Некоректний формат електронної пошти",
},
})}
/>Помилки для конкретного елемента потрібно отримувати за його індексом:
const error = errors.contacts?.[index]?.email;
return error?.message ? <p>{error.message}</p> : null;Під час рендерингу списку важливо не звертатися до помилки без перевірки:
errors.contacts[index].email.messageЯкщо елемент ще не має помилки або індекс більше не існує, це спричинить помилку під час виконання. Використовуйте optional chaining:
errors.contacts?.[index]?.email?.messageОкремі поля можуть бути валідними, але сам список — некоректним. Наприклад, форма повинна містити хоча б один контакт.
Для цього можна додати правила в useFieldArray:
const { fields, append, remove } = useFieldArray({
control,
name: "contacts",
rules: {
minLength: {
value: 1,
message: "Потрібен хоча б один контакт",
},
maxLength: {
value: 10,
message: "Не можна додати більше 10 контактів",
},
},
});Помилка масиву доступна через root:
{errors.contacts?.root?.message && (
<p role="alert">{errors.contacts.root.message}</p>
)}Це відрізняється від помилки конкретного поля:
errors.contacts?.[index]?.name?.messageerrors.contacts?.[index] — помилка одного елемента;
errors.contacts?.root — помилка всього масиву.
Іноді останній елемент не можна видалити. У такому разі кнопку можна вимкнути:
<button
type="button"
disabled={fields.length === 1}
onClick={() => remove(index)}
>
Видалити
</button>Але вимкнення кнопки — це лише поведінка інтерфейсу. Якщо правило важливе для бізнес-логіки, його також потрібно перевіряти під час обробки даних або серверної валідації.
Інший варіант — дозволити видалити всі елементи й показати помилку minLength для порожнього масиву.
Кожна група полів повинна мати зрозумілу структуру. Для повторюваних груп зручно використовувати fieldset і legend:
<fieldset key={field.id}>
<legend>Контакт {index + 1}</legend>
<label htmlFor={`contacts-${index}-name`}>Імʼя</label>
<input
id={`contacts-${index}-name`}
{...register(`contacts.${index}.name`)}
/>
</fieldset>Для повідомлень про помилки використовуйте role="alert":
{error?.message && <p role="alert">{error.message}</p>}Ідентифікатори id повинні бути унікальними для кожного елемента. Використання індексу в id допустиме, оскільки він потрібен для звʼязку label з поточним полем, а не як React-ключ.
keyНеправильно:
{fields.map((field, index) => (
<div key={index}>
{/* поля */}
</div>
))}Правильно:
{fields.map((field, index) => (
<div key={field.id}>
{/* поля */}
</div>
))}field.id стабільно ідентифікує елемент навіть після вставки або видалення сусідніх елементів.
Неправильно:
<input {...register(`name-${index}`)} />Таке поле не буде частиною масиву contacts.
Правильно:
<input {...register(`contacts.${index}.name`)} />Шлях повинен відповідати структурі даних форми.
appendНеправильно:
append({
name: "",
});Якщо модель передбачає name, email і role, краще передати всі властивості:
append({
name: "",
email: "",
role: "",
});useState і useFieldArrayНе потрібно дублювати масив у локальному стані:
const [contacts, setContacts] = useState<Contact[]>([]);Якщо поля зареєстровані через React Hook Form, джерелом правди має бути сама форма.
update для кожного введеного символуupdate призначений для заміни всього елемента. Не викликайте його в onChange кожного поля без потреби.
Для звичайного введення використовуйте register. Для програмної зміни одного значення — setValue.
defaultValues після завантаження данихЗміна обʼєкта defaultValues після створення форми не оновлює її автоматично. Для нових даних використовуйте:
reset(serverData);або:
replace(serverData.contacts);useFieldArray призначений для повторюваних груп полів.
fields потрібно відображати через map.
Для key використовуйте field.id, а не index.
Імена полів мають містити шлях на кшталт contacts.${index}.email.
append додає елементи, remove видаляє, update замінює елемент, а replace замінює весь масив.
Дані, завантажені після ініціалізації форми, потрібно встановлювати через reset або replace.
Окремі поля та весь масив можуть мати власні правила валідації.
Не дублюйте масив форми в локальному стані без необхідності.