Коротко: Руководство по типизации динамических массивов полей в React с useFieldArray и Zod. Валидация, вывод типов через z.infer, field.id и декомпозиция.
Разработка динамических форм со списками сущностей — счетами, списками участников, вариативными характеристиками товаров — часто приводит к двум крайностям: потере строгой типизации из-за использования any во вложенных путях или рассинхронизации TypeScript-интерфейсов с валидацией в рантайме.
Связка React Hook Form (RHF) и Zod решает обе задачи. Схема Zod становится единым источником правды (Single Source of Truth, SSOT), гарантируя корректность данных на этапе компиляции и точную валидацию пользовательского ввода без лишних перерисовок интерфейса.
Почему динамические формы вызывают сложности в TypeScript
При классическом подходе разработчик вручную описывает интерфейс формы в TypeScript и отдельно пишет логику валидации (или схему). При любых изменениях требований приходится синхронизировать типы и правила в двух местах. Если в схеме поле стало обязательным, а в интерфейсе забыли убрать знак ?, приложение скомпилируется, но упадет при сабмите.
В динамических массивах полей сложность возрастает:
- TypeScript должен корректно резолвить строковые пути вида
items.${number}.titleв методеregister. - Хук
useFieldArrayдолжен знать точную структуру конкретного среза формы, а не всего состояния. - Методы мутации массива (
append,insert,replace) должны требовать строго типизированные объекты с дефолтными значениями.
Использование z.infer в паре с @hookform/resolvers/zod исключает ручное дублирование: типы генерируются автоматически непосредственно из правил валидации.
Проектирование схемы Zod для вложенных массивов
Любая динамическая форма начинается с описания контракта данных. Сначала описывается схема отдельного элемента массива, затем — общая схема формы.
Описание элемента и валидация длины массива
Для описания полей элемента используется стандартный z.object(). Для массива применяются модификаторы длины: .min(), .max() или .nonempty().
import { z } from 'zod';
// Схема отдельного элемента динамического массива
export const itemSchema = z.object({
title: z
.string()
.trim()
.min(2, 'Название должно содержать не менее 2 символов'),
quantity: z
.number({ invalid_type_error: 'Укажите число' })
.int('Количество должно быть целым числом')
.min(1, 'Минимум 1 шт.'),
price: z
.number({ invalid_type_error: 'Укажите цену' })
.positive('Цена должна быть больше нуля'),
});
// Корневая схема формы
export const invoiceFormSchema = z.object({
invoiceNumber: z.string().min(1, 'Укажите номер счета'),
clientName: z.string().min(2, 'Укажите имя клиента'),
items: z
.array(itemSchema)
.min(1, 'Добавьте хотя бы одну позицию в счет')
.max(50, 'Нельзя добавить более 50 позиций'),
});
Вывод типов через z.infer
Не создавайте интерфейсы interface InvoiceForm вручную. Типы формы выводятся через z.infer:
export type InvoiceFormValues = z.infer<typeof invoiceFormSchema>;
export type InvoiceItemValues = z.infer<typeof itemSchema>;
Благодаря этому типы и правила валидации всегда синхронизированы: при добавлении поля в itemSchema TypeScript автоматически обновит требования к useFieldArray и defaultValues.
Инициализация формы и связка useForm с zodResolver
Интеграция RHF со схемой Zod осуществляется через резолвер из пакета @hookform/resolvers/zod. При инициализации useForm тип формы передается в дженерик хука.
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { invoiceFormSchema, InvoiceFormValues } from './schema';
export const InvoiceForm: React.FC = () => {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<InvoiceFormValues>({
resolver: zodResolver(invoiceFormSchema),
defaultValues: {
invoiceNumber: '',
clientName: '',
items: [
{
title: '',
quantity: 1,
price: 0,
},
],
},
mode: 'onTouched',
});
const onSubmit = (data: InvoiceFormValues) => {
console.log('Отправленные данные:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Поля формы */}
</form>
);
};
Правило:
defaultValuesдля массивов обязательны. Если массив изначально пустой, укажитеitems: []. Не оставляйте свойствоundefined, иначе RHF не сможет корректно инициализировать внутреннее состояние хукаuseFieldArray.
Реализация useFieldArray с полной типизацией
Хук useFieldArray подключается к конкретному массиву в форме через объект control и строковое имя поля name.
import { useFieldArray, Control } from 'react-hook-form';
import { InvoiceFormValues, InvoiceItemValues } from './schema';
interface ItemsListProps {
control: Control<InvoiceFormValues>;
}
export const ItemsList: React.FC<ItemsListProps> = ({ control }) => {
const { fields, append, remove, swap, move, replace } = useFieldArray({
control,
name: 'items', // TypeScript подсказывает доступные пути-массивы из InvoiceFormValues
});
const handleAddNewItem = () => {
const newItem: InvoiceItemValues = {
title: '',
quantity: 1,
price: 0,
};
// Метод append строго требует объект типа InvoiceItemValues
append(newItem);
};
// ...
};
Методы манипуляции массивом
append(item | item[])— добавление элемента в конец списка.prepend(item | item[])— добавление элемента в начало списка.insert(index, item | item[])— вставка по индексу.remove(index | index[])— удаление элемента. Если вызватьremove()без аргументов, очистится весь массив.swap(indexA, indexB)— обмен местами двух элементов.move(fromIndex, toIndex)— перемещение элемента на новую позицию.replace(items)— полная замена содержимого массива.
Требование: Никогда не передавайте пустой объект
{}в методыappend,prependилиinsert. Все ключи, объявленные в схеме, должны иметь значения по умолчанию. Передача пустого объекта нарушает типы и приводит к рассинхронизации неконтролируемых инпутов.
Рендеринг и регистрация полей: field.id против index
Главный источник багов при работе с динамическими списками — использование index в качестве свойства key при рендеринге элементов.
Почему key={field.id} обязателен
RHF генерирует уникальный идентификатор id для каждого элемента массива и сохраняет его в объекте поля (field.id). Если использовать key={index}, то при удалении элемента из середины списка React сопоставит DOM-узлы по индексам, что приведет к сохранению состояния неконтролируемых инпутов и визуальным артефактам.
{fields.map((field, index) => (
// ПРАВИЛЬНО: field.id гарантирует стабильную идентификацию в Virtual DOM
<div key={field.id} className="item-row">
<input
{...register(`items.${index}.title` as const)}
placeholder="Название позиции"
/>
<input
type="number"
{...register(`items.${index}.quantity` as const, { valueAsNumber: true })}
placeholder="Кол-во"
/>
<input
type="number"
step="0.01"
{...register(`items.${index}.price` as const, { valueAsNumber: true })}
placeholder="Цена"
/>
<button type="button" onClick={() => remove(index)}>
Удалить
</button>
</div>
))}
Обработка ошибок валидации
Ошибки вложенных элементов доступны в объекте formState.errors. Доступ к ним осуществляется по цепочке свойств:
const titleError = errors.items?.[index]?.title?.message;
const arrayRootError = errors.items?.message || errors.items?.root?.message;
return (
<div>
{arrayRootError && <p className="error-root">{arrayRootError}</p>}
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.title` as const)} />
{errors.items?.[index]?.title && (
<span className="error">{errors.items[index]?.title?.message}</span>
)}
</div>
))}
</div>
);
Декомпозиция: передача control во вложенные компоненты без any
В реальных интерфейсах строки динамического списка выносятся в отдельные компоненты для оптимизации рендеринга и читаемости кода. Чтобы сохранить строгую типизацию, пропсы дочерних компонентов должны принимать типизированный Control или методы register.
Вариант 1: Явная передача Control и Register
import React from 'react';
import { Control, UseFormRegister, FieldErrors } from 'react-hook-form';
import { InvoiceFormValues } from './schema';
interface ItemRowProps {
index: number;
control: Control<InvoiceFormValues>;
register: UseFormRegister<InvoiceFormValues>;
errors: FieldErrors<InvoiceFormValues>;
onRemove: (index: number) => void;
}
export const ItemRow: React.FC<ItemRowProps> = ({
index,
register,
errors,
onRemove,
}) => {
const itemErrors = errors.items?.[index];
return (
<div className="flex gap-4 items-start">
<div>
<input
{...register(`items.${index}.title` as const)}
className={itemErrors?.title ? 'input-error' : ''}
/>
{itemErrors?.title?.message && (
<p className="text-sm text-red-500">{itemErrors.title.message}</p>
)}
</div>
<div>
<input
type="number"
{...register(`items.${index}.quantity` as const, { valueAsNumber: true })}
className={itemErrors?.quantity ? 'input-error' : ''}
/>
</div>
<button type="button" onClick={() => onRemove(index)}>
Удалить
</button>
</div>
);
};
Вариант 2: Использование FormProvider и useFormContext
Если форма имеет глубокую вложенность, передача пропсов становится громоздкой. В таких случаях используется контекст RHF:
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { invoiceFormSchema, InvoiceFormValues } from './schema';
export const FormWrapper = () => {
const methods = useForm<InvoiceFormValues>({
resolver: zodResolver(invoiceFormSchema),
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((data) => console.log(data))}>
<NestedItemsList />
</form>
</FormProvider>
);
};
export const NestedItemsList = () => {
const { control, register, formState: { errors } } = useFormContext<InvoiceFormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.title` as const)} />
{errors.items?.[index]?.title?.message && (
<span>{errors.items[index]?.title?.message}</span>
)}
<button type="button" onClick={() => remove(index)}>Удалить</button>
</div>
))}
<button
type="button"
onClick={() => append({ title: '', quantity: 1, price: 0 })}
>
Добавить
</button>
</div>
);
};
Типичные ошибки и подводные камни (Troubleshooting)
1. Ошибка типов FieldArrayPath в строке name
Проблема: TypeScript выдает ошибку Type '"items"' is not assignable to type 'FieldArrayPath<InvoiceFormValues>'.
Причина: В схеме поле items не является массивом или в дженерик useForm не был передан тип схемы.
Решение: Убедитесь, что в Zod поле объявлено через z.array(...), а useForm инициализирован как useForm<FormValues>().
2. Обновление всего массива через setValue вместо replace
Проблема: Попытка сбросить или обновить весь список через setValue('items', newItems).
Причина: setValue обновляет значения в стейте формы, но не синхронизирует внутренний реестр хука useFieldArray (список сгенерированных id). Это приводит к рассинхронизации инпутов и визуальным сбоям.
Решение: Для перезаписи массива всегда используйте метод replace из деструктуризации useFieldArray:
const { replace } = useFieldArray({ control, name: 'items' });
const handleResetItems = (freshItems: InvoiceItemValues[]) => {
replace(freshItems);
};
3. Парсинг числовых полей
Проблема: Инпуты со значением type="number" возвращают строки в DOM (event.target.value), из-за чего валидация Zod z.number() падает с ошибкой типа.
Решение: Обязательно указывайте флаг { valueAsNumber: true } при вызове register:
<input
type="number"
{...register(`items.${index}.price` as const, { valueAsNumber: true })}
/>
FAQ
Почему TypeScript выдает ошибку на литерал пути в register?
При динамической конкатенации строк вида `items.${index}.title` TypeScript без явного указания считает строку обычным string, а не допустимым шаблонным литералом пути формы. Добавьте утверждение типа as const: `items.${index}.title` as const.
Зачем использовать field.id в key, если есть index?
field.id генерируется библиотекой React Hook Form для каждого элемента и остается неизменным при перестановке, удалении или добавлении элементов. Если использовать index, при удалении первого элемента React переиспользует существующие DOM-узлы по их порядковым номерам, что приведет к сохранению неконтролируемого состояния инпутов.
Как задать ограничение: «массив должен содержать минимум один элемент»?
В Zod-схеме используйте метод .min(1, 'Сообщение об ошибке') или .nonempty('Сообщение об ошибке') на уровне z.array(). Ошибка будет доступна в объекте errors.items?.root?.message или errors.items?.message.
Как реализовать вложенный массив полей (массив внутри массива)?
Для каждого уровня вложенности вызывается отдельный хук useFieldArray. Во вложенный хук передается тот же корневой control, а в качестве name указывается полный путь с индексом родительского элемента, например: name: `items.${parentIndex}.subItems` as const.
Можно ли передавать в append() неполный объект?
Нет. Объект, передаваемый в append, prepend или insert, должен содержать все поля, объявленные в схеме Zod для данного элемента. Неполные объекты приводят к тому, что неконтролируемые поля получают значение undefined, что вызывает ошибки рендеринга и валидации.
Почему при отправке формы числовые инпуты в объекте данных приходят как NaN?
Если пользователь очистил числовой инпут с флагом valueAsNumber: true, браузер преобразует пустую строку в NaN. Чтобы корректно обработать эту ситуацию, в схеме Zod используйте препроцессинг или трансформацию, например: z.preprocess((val) => (isNaN(val as number) ? undefined : val), z.number().min(0)).
Заключение и Best Practices
Архитектура динамических форм строится на трех базовых правилах:
- Единый источник правды: Интерфейсы TypeScript генерируются исключительно через
z.infer<typeof schema>. Создавать дублирующие типы формы вручную не нужно. - Изоляция DOM-состояния: Все циклы рендеринга
fields.mapобязаны использоватьkey={field.id}. - Корректные методы мутации: Любые добавления элементов выполняются с полным набором дефолтных значений, а полная замена массива — через
replace, а неsetValue.
Такой подход защищает кодовую базу от регрессий при масштабировании сложных корпоративных интерфейсов и обеспечивает максимальную производительность без лишних ререндеров.




.svg.webp)





