Динамическое добавление и удаление полей

При работе с формами в JavaScript-экосистеме, особенно в связке с React Hook Form и Yup через @hookform/resolvers/yup, основная сложность динамических интерфейсов заключается не в добавлении или удалении DOM-элементов, а в согласовании структуры данных формы и схемы валидации.

Динамические поля почти всегда реализуются через массивы объектов, где каждый элемент массива соответствует одному экземпляру повторяемой группы полей. Именно такой подход позволяет сохранять строгую типизацию и предсказуемую валидацию даже при изменении количества элементов.


Модель данных для динамических полей

Типовая структура формы с динамическими полями строится вокруг массива:

{
  users: [
    { name: "", email: "" },
    { name: "", email: "" }
  ]
}

Каждый объект внутри массива должен иметь одинаковую структуру. Это критически важно, поскольку Yup валидирует не «форму как набор полей», а структуру данных целиком.


Схема Yup для массива объектов

Базовая схема строится через array().of(object()):

import * as yup from "yup";

const schema = yup.object({
  users: yup.array().of(
    yup.object({
      name: yup.string().required("Имя обязательно"),
      email: yup.string().email("Некорректный email").required("Email обязателен")
    })
  )
});

Ключевой момент заключается в том, что of() определяет правила для каждого элемента массива, а не для массива в целом.

Дополнительно можно управлять ограничениями:

users: yup.array()
  .min(1, "Минимум один пользователь")
  .max(10, "Максимум 10 пользователей")

Связь YupResolver и React Hook Form

YupResolver выступает адаптером между Yup-схемой и механизмом валидации React Hook Form:

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";

const form = useForm({
  resolver: yupResolver(schema),
  defaultValues: {
    users: []
  }
});

Важно понимать, что resolver не «следит» за изменениями схемы во времени. Он получает схему один раз при инициализации формы. Поэтому динамика должна решаться на уровне структуры данных, а не перестроения схемы.


Добавление полей через useFieldArray

Механизм динамики реализуется через useFieldArray:

import { useFieldArray } from "react-hook-form";

const { fields, append, remove } = useFieldArray({
  control,
  name: "users"
});

Добавление нового элемента:

append({
  name: "",
  email: ""
});

Удаление элемента:

remove(index);

Каждое изменение массива автоматически синхронизируется с Yup-схемой, поскольку структура данных остаётся неизменной.


Поведение Yup при изменении длины массива

Yup не требует «пересборки» схемы при добавлении или удалении элементов. Валидатор работает с актуальным значением массива в момент валидации.

Это означает:

  • добавление элемента → новый объект попадает под .of()
  • удаление элемента → соответствующий объект исчезает из проверки
  • индексы массива не фиксируются схемой

Валидация обязательных и необязательных динамических элементов

Часто требуется различать обязательные и опциональные элементы массива.

users: yup.array().of(
  yup.object({
    name: yup.string().required(),
    email: yup.string().email()
  })
)

Если необходимо разрешить пустой массив, важно не добавлять .min(1).

Если требуется минимум один заполненный элемент:

users: yup.array()
  .min(1, "Добавьте хотя бы одного пользователя")

Условная валидация внутри динамических полей

Динамические формы часто требуют зависимых правил внутри одного элемента массива.

yup.object({
  name: yup.string().required(),
  email: yup.string().when("name", {
    is: (val) => val && val.length > 0,
    then: (schema) => schema.required("Email обязателен при наличии имени"),
    otherwise: (schema) => schema.notRequired()
  })
})

При использовании внутри массива контекст when применяется к каждому элементу отдельно.


Динамическая валидация через yup.lazy

Когда структура элемента может меняться в зависимости от типа данных:

users: yup.array().of(
  yup.lazy((value) => {
    if (value?.type === "admin") {
      return yup.object({
        type: yup.string(),
        permissions: yup.array().min(1)
      });
    }

    return yup.object({
      type: yup.string(),
      accessLevel: yup.number().required()
    });
  })
)

lazy() позволяет менять схему на уровне каждого элемента массива, что критично для сложных динамических форм.


Проблема несоответствия defaultValues и схемы

Одна из частых ошибок — расхождение между defaultValues и Yup-схемой:

defaultValues: {
  users: undefined
}

или

defaultValues: {
  users: null
}

Yup ожидает массив, поэтому безопасный вариант:

defaultValues: {
  users: []
}

Синхронизация удаления полей и unregister

React Hook Form по умолчанию сохраняет значения даже после удаления поля. Это может приводить к тому, что удалённый элемент массива всё ещё участвует в валидации.

Для корректной синхронизации:

useForm({
  resolver: yupResolver(schema),
  shouldUnregister: true
});

Это обеспечивает удаление значений из внутреннего состояния при удалении элементов массива.


Валидация при изменении структуры массива

При динамическом добавлении и удалении важно учитывать момент триггера валидации:

  • mode: "onChange" — валидация каждого изменения
  • mode: "onBlur" — валидация при потере фокуса
  • mode: "onSubmit" — проверка только при отправке
useForm({
  resolver: yupResolver(schema),
  mode: "onChange"
});

Типовые ошибки при динамических полях

Основные проблемы возникают не в Yup, а в несогласованности структуры:

  1. Использование null вместо массива
  2. Разные структуры объектов в одном массиве
  3. Отсутствие defaultValues для массива
  4. Попытка динамически менять schema после инициализации resolver
  5. Несоответствие индексов массива при ручном рендеринге без field.id

Работа с ключами элементов массива

React Hook Form использует field.id для стабильной идентификации элементов:

fields.map((field, index) => (
  <input key={field.id} {...register(`users.${index}.name`)} />
))

Использование индекса как key приводит к рассинхронизации значений при удалении элементов.


Масштабирование динамических структур

При усложнении формы массивы могут быть вложенными:

{
  company: {
    departments: [
      {
        name: "",
        employees: [
          { name: "", role: "" }
        ]
      }
    ]
  }
}

Схема Yup строится рекурсивно:

yup.object({
  company: yup.object({
    departments: yup.array().of(
      yup.object({
        name: yup.string().required(),
        employees: yup.array().of(
          yup.object({
            name: yup.string().required(),
            role: yup.string().required()
          })
        )
      })
    )
  })
})

Поведение resolver при ре-рендере формы

YupResolver не пересоздаёт схему автоматически при изменении состояния формы. Он работает как чистая функция:

values → schema → errors

Поэтому динамика формы должна быть полностью выражена через данные, а не через изменение схемы во времени.


Оптимизация валидации больших динамических массивов

При работе с большими массивами (50–500 элементов) важно учитывать:

  • использование validate только на submit
  • избегание глубоких .when() внутри каждого элемента
  • минимизация вычисляемых схем
  • мемоизация схемы через useMemo
const schema = useMemo(() => createSchema(), []);

Это снижает стоимость повторных вычислений resolver.


Управление частичной валидацией массива

Иногда требуется валидировать только изменённый элемент:

trigger(`users.${index}`);

Это ограничивает область проверки одним объектом внутри массива, не затрагивая остальные элементы.


Поведение при удалении элементов и индексации

Удаление элемента приводит к сдвигу индексов:

0, 1, 2 → удаление 1 → 0, 1

Yup не отслеживает старые индексы, поэтому логика всегда должна опираться на текущее состояние массива, а не на сохранённые ссылки.


Использование transform для нормализации массива

Иногда полезно нормализовать данные перед валидацией:

users: yup.array().transform((value) => value || [])

Это защищает от undefined и null при неполной инициализации формы.