Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

React Hook Form + Zod: типизация динамических массивов с useFieldArray в TypeScript

Тимофей Ищенко

Коротко: Руководство по типизации динамических массивов полей в 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

Архитектура динамических форм строится на трех базовых правилах:

  1. Единый источник правды: Интерфейсы TypeScript генерируются исключительно через z.infer<typeof schema>. Создавать дублирующие типы формы вручную не нужно.
  2. Изоляция DOM-состояния: Все циклы рендеринга fields.map обязаны использовать key={field.id}.
  3. Корректные методы мутации: Любые добавления элементов выполняются с полным набором дефолтных значений, а полная замена массива — через replace, а не setValue.

Такой подход защищает кодовую базу от регрессий при масштабировании сложных корпоративных интерфейсов и обеспечивает максимальную производительность без лишних ререндеров.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также