Форма описания займа с виду выглядит обычно: сумма, срок, ставка. Но стоит добавить несколько режимов, залоги, созаёмщиков и 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.