Форма описания займа с виду выглядит обычно: сумма, срок, ставка. Но стоит добавить несколько режимов, залоги, созаёмщиков и realtime-обратную связь, и выясняется, что проблема уже не в валидации отдельных полей, а в том, как вся эта система остаётся управляемой при росте требований.

В этой статье разберём, как выстроить архитектуру формы, которая не потребует переписывания через полгода. На примере реальной формы займа: зависимые поля, условные схемы, валидация массивов с проверкой уникальности, асинхронные проверки и контроль ререндеров.

Когда этот стек оправдан

Главный критерий — не количество полей, а то, стали ли изменения нелокальными. Если правка в одном месте требует изменений в трёх других, форма уже переросла useState.

Типичные признаки, что пора уходить:

  • поля зависят друг от друга: выбор режима меняет требования к другим полям
  • есть create и edit mode, и важно корректно вести dirty/touched
  • есть массивы объектов: списки залогов, созаёмщиков, документов
  • форма собирается из вложенных компонентов и повторно используемых полей
  • есть серверные ошибки на конкретные поля и общий root-уровень

Если полей много, но они независимы, RHF может быть лишней сложностью. Если есть связи и ветвления, RHF упрощает управление подписками, а Yup даёт единое место для бизнес-правил и нормализации.

Как было и как стало

Форма займа начиналась как линейный список полей в одном компоненте. Состояние в useState, валидация вручную на сабмите, условные поля через тернарники прямо в JSX. На первых двадцати полях это работало. Когда появились залоги (массив объектов с собственными правилами) и созаёмщик (ещё один условный раздел), компонент стал разрастаться, а логика валидации начала дублироваться.

До:

LoanForm.tsx
  - 400+ строк
  - useState для каждого поля
  - валидация вручную в onSubmit
  - условные поля через флаги в state
  - преобразование данных прямо перед fetch

После:

features/loan/
  loan.schema.ts       // правила и нормализация
  loan.mappers.ts      // DTO <-> Form
  useLoanForm.ts       // жизненный цикл и submit
  LoanForm.tsx         // композиция UI
  CollateralFields.tsx
  CoBorrowerFields.tsx

Каждый слой решает одну задачу. Схема не знает про компоненты. Маппер не знает про валидацию. Хук не знает про вёрстку. Это и есть архитектурная граница, которую не придётся переосмысливать при добавлении нового раздела формы.

Зависимые поля и conditional-секции: где хранить правила

В форме займа классика: rateType переключает режим и меняет требования к fixedRate. Та же история с чекбоксами, которые включают секции. Ключевой принцип: условность должна жить в одном месте, лучше в схеме, а не в JSX и не в submit.

Минимальный пример схемы с нормализацией numeric input и when():

import * as yup from "yup";

// <input type="number"> возвращает строку "" при пустом значении.
// Без transform Yup получает строку там, где ожидает число,
// и required() ведёт себя неожиданно.
const numericField = () =>
  yup
    .number()
    .transform((value, originalValue) => (originalValue === "" ? undefined : value))
    .typeError("Введите число");

export const loanSchema = yup.object({
  rateType: yup.string().oneOf(["fixed", "floating"]).required(),

  fixedRate: numericField().when("rateType", {
    is: "fixed",
    then: (s) => s.required("Введите ставку").min(0).max(100),
    otherwise: (s) => s.strip(),
  }),

  hasCollateral: yup.boolean().default(false),

  collateral: yup
    .array()
    .of(
      yup.object({
        type: yup.string().required(),
        value: numericField().required().min(0),
      })
    )
    .when("hasCollateral", {
      is: true,
      then: (s) => s.min(1, "Добавьте хотя бы один залог").required(),
      otherwise: (s) => s.strip(),
    }),
});

numericField() переиспользуется для всех числовых полей формы: principal, termMonths, fixedRate, collateral[].value. Правило нормализации определено один раз.

Три слоя, три разных задачи:

  • strip() удаляет поле из validated output схемы. Работает только если вы используете output резолвера: при raw: true в настройках @hookform/resolvers схема возвращает сырые значения и strip() не применяется
  • unregister и shouldUnregister управляют тем, что хранит RHF: touched, dirty, errors, участие поля в валидации
  • маппер перед отправкой — это финальная гарантия: скрытое поле не должно улететь в API даже при сбое логики UI

В форме займа мы опирались на маппер как на последнюю линию защиты. strip() давал удобство, но не был единственной точкой очистки.

Числовые поля и пустые инпуты

Ловушка RHF + Yup: <input type=”number”> возвращает строку “” при пустом значении. Yup получает строку там, где ожидает число, и required() ведет себя неожиданно. Поэтому нормализацию стоит держать в одном месте (numericField), а в схеме задавать только ограничения.

Отдельно про деньги: умножение и деление на 100 на number может дать ошибки округления. Для финансовых значений используйте Math.round() в маппере или decimal-библиотеку, если точность критична.

Большие числовые значения: когда number недостаточно

Важно учитывать ограничение JavaScript:

Number.MAX_SAFE_INTEGER // 2^53 - 1

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

В одном из продакшн-проектов мы сознательно отказались от number и BigInt и хранили числовые значения в форме как строки.

const amountSchema = yup
  .string()
  .matches(/^\d+$/, "Введите корректное число")
  .required();

Почему строка:

  • нет потери точности
  • BigInt не сериализуется в JSON напрямую
  • сервер принимал значения как строки
  • вся арифметика выполнялась на бэкенде

В таком подходе важно разделять:

  • формат хранения в форме (string)
  • валидацию формата (regex / schema)
  • арифметику (на сервере или в отдельном сервисе)

Если форма не выполняет вычислений на клиенте, строковое хранение — простой и безопасный способ избежать проблем с precision.

Валидация массивов и объектов: «уникальность» в списках

Залоги в форме займа — это массив объектов: тип залога и его оценочная стоимость. Yup позволяет описать схему каждого элемента через .of(), и это работает корректно в паре с useFieldArray.

Сложнее с уникальностью. В нашем случае тип залога не должен повторяться. Yup не предоставляет встроенной проверки уникальности внутри массива, но это решается через .test():

collateral: yup.array()
  .of(collateralItemSchema)
  .test(
    "unique-types",
    "Типы залогов не должны повторяться",
    (items) => {
      if (!items) return true;
      const types = items.map((i) => i.type).filter(Boolean);
      return types.length === new Set(types).size;
    }
  )
  .when("hasCollateral", {
    is: true,
    then: (s) => s.min(1, "Добавьте хотя бы один залог").required(),
    otherwise: (s) => s.strip(),
  }),

.test() на уровне массива даёт одно сообщение об ошибке для всего массива. Если нужно подсветить конкретные строки, придётся возвращать ValidationError с path через createError или делать проверку на уровне UI. В нашем случае одного сообщения было достаточно.

Асинхронные проверки

Realtime-проверка в форме займа часто упирается в сервер: например, не превышает ли сумма доступный лимит пользователя.

Yup поддерживает async в .test(), но с сетью есть два риска:

  • при mode: “onChange” можно получить запрос на каждый символ, нужен blur или debounce
  • Yup не отменяет предыдущий вызов, возможны гонки ответов

Для критичных случаев управляйте отменой запроса в кастомном хуке или сервисе, используя AbortController. Обычно схему оставляют синхронной, а сетевые проверки выносят в отдельный хук, который при необходимости вызывает setError.

DTO vs Form: маппинг как архитектурный инвариант

FormValues — это модель того, что вводит пользователь. API DTO — это контракт сервера. Они обязаны расходиться, иначе ограничения одного слоя проникают в другой.

В форме займа расхождений было несколько: суммы хранились в копейках на сервере, но отображались в рублях; поле hasCollateral существовало только в форме (на сервере есть только пустой или непустой массив залогов); названия полей отличались от snake_case к camelCase.

export function mapFormToApi(values: LoanFormValues): SubmitLoanRequest {
  return {
    principal_amount: Math.round(values.principal * 100),
    term_months: values.termMonths,
    rate_type: values.rateType,
    fixed_rate: values.rateType === "fixed" ? values.fixedRate : null,
    collateral_items: values.hasCollateral
      ? (values.collateral ?? []).map((i) => ({
          collateral_type: i.type,
          estimated_value: Math.round(i.value * 100),
        }))
      : [],
  };
}

Маппер — это последний gate перед API. Даже если strip() не сработал, даже если unregister повёл себя неожиданно, маппер явно контролирует финальный payload. Тесты маппера не менее важны, чем тесты схемы: здесь живут деньги, null-кейсы и преобразования массивов.

Edit mode и reset

Форма займа работала в двух режимах: создание новой заявки и редактирование черновика. reset() с готовым объектом значений — это правильный выбор: он синхронизирует values, defaultValues и состояние dirty/touched одновременно. Если использовать setValue() для каждого поля по отдельности, isDirty будет считаться некорректно.

Один нюанс: если initialData передаётся как объект и пересоздаётся родителем на каждом рендере, reset будет вызываться бесконечно. initialData должен быть ссылочно стабильным через useMemo на стороне вызывающего компонента.

Производительность: где живут ререндеры

В форме с большим количеством полей и ветвлений легко случайно сделать так, что изменение одного поля перерисовывает всё дерево.

watch() без аргументов подписывает компонент на всю форму. Если вызвать в корневом компоненте, любое изменение перерендерит всё дерево. useWatch() позволяет опустить подписку вниз, к тому компоненту, которому она нужна, и локализовать ререндер. Для точечных зависимостей предпочитайте useWatch.

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

Для изоляции подписок в глубоко вложенных компонентах используйте useFormState:

// Компонент подписывается только на errors.collateral, не на весь formState
const { errors } = useFormState({ control, name: "collateral" });

Для разового чтения значения без создания подписки используйте getValues() внутри обработчиков событий.

Таблица gotchas

Проблема Последствие Решение
<button> без type=”button” Форма сабмитится при нажатии кнопки удаления Всегда указывать type=”button”
append({}) Баги dirty/touched при первом сабмите Передавать объект с дефолтами для всех полей
index как key в useFieldArray Потеря фокуса и значений при удалении из середины Только field.id
watch() без аргументов в корне Ререндер всего дерева при любом изменении useWatch() или getValues()
formState целиком в пропс Лишние ререндеры, оптимизация отключается Деструктурировать нужные поля
boolean().required() Форма проходит валидацию с false Для обязательного согласия .oneOf([true])
setValue() вместо reset() в edit mode isDirty считается некорректно reset(mapApiToForm(data))
keyof вместо Path для server errors Нет проверки путей, легко поставить ошибку не туда Path<LoanFormValues> из react-hook-form
* 100 / / 100 на number Ошибки округления в финансовых данных Math.round() или decimal-библиотека
Нестабильный initialData в useEffect Бесконечный вызов reset useMemo на стороне вызывающего
Async .test() без debounce Запрос на каждый символ при mode: “onChange” mode: “onBlur” для полей с async-валидацией
raw: true у resolve strip() не применяется, поля уходят в payload Оставить raw: false или чистить в маппере

Итог

Архитектура schema / mappers / hook / UI держит форму управляемой при росте требований. Схема тестируется изолированно, маппер защищает API от мусора, хук изолирует логику от рендеринга. Каждый новый раздел добавляется в понятное место, не затрагивая соседей. Когда хук начинает вбирать несвязанные доменные зоны, его пора делить.

Если начинаете новый проект, смотрите в сторону Zod, Valibot или ArkType: экосистема @hookform/resolvers поддерживает все три, а TypeScript inference там предсказуемее, чем в сложных when/strip конструкциях Yup.