complex-forms-validation.md — vim

Формы на сорок полей: как не сойти с ума

Схема вместо ручных if-ов, ошибки бэка в поля формы, зависимые блоки и черновики. Всё, до чего я дошёл на корпоративных заявках в четыре экрана.


Форма на три поля пишется за десять минут любым способом, и спорить тут не о чем. А вот форма на сорок полей, с шагами, зависимыми блоками и валидацией на бэке — это отдельная подсистема, которую кто-то потом будет поддерживать.

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

Правила — это данные

Ручные проверки расползаются и дублируются:

if (!form.email) errors.email = 'Обязательное поле';
else if (!/.+@.+/.test(form.email)) errors.email = 'Некорректный email';
if (form.password.length < 8) errors.password = 'Минимум 8 символов';
// и так сорок раз

Схема описывает то же самое, но её можно переиспользовать — в тестах, для проверки данных с сервера, для генерации подсказок:

export const applicationSchema = z.object({
  email: z.string().min(1, 'Обязательное поле').email('Некорректный email'),
  inn:   z.string().regex(/^\d{10}$|^\d{12}$/, 'ИНН - 10 или 12 цифр'),
  sum:   z.coerce.number().positive('Сумма должна быть больше нуля'),
});

export type ApplicationForm = z.infer<typeof applicationSchema>;

Тип формы выводится из схемы, отдельный interface поддерживать не надо.

const { handleSubmit, errors, defineField, setErrors } = useForm({
  validationSchema: toTypedSchema(applicationSchema),
});

Ошибки бэка — это ошибки полей

Самая частая недоделка, которую я вижу: сервер ответил «ИНН уже зарегистрирован», а пользователь получил красный тост в углу экрана. И сидит гадает, какое из сорока полей чинить.

Раз ошибка у нас нормализована (об этом в заметке про слой API), маппинг делается в одну строчку:

const onSubmit = handleSubmit(async (values) => {
  try {
    await applicationApi.create(values);
  } catch (e) {
    const err = e as AppError;
    if (err.fields) setErrors(err.fields);  // { inn: 'ИНН уже зарегистрирован' }
    else notify.error(err.message);
  }
});

И скролл к первому невалидному полю. На длинной форме без этого человек просто не найдёт, где ошибка:

document.querySelector('[data-invalid="true"]')?.scrollIntoView({ block: 'center' });

Момент валидации

Если подсвечивать ошибку с первого символа, поле краснеет раньше, чем человек успел напечатать хоть что-то осмысленное. Раздражает страшно.

Нормальное поведение:

  • до первого blur молчим;
  • после blur валидируем;
  • после первой неудачной отправки - валидируем на ввод, чтобы человек видел, что ошибка ушла.

В vee-validate это validateOnBlur и validateOnModelUpdate, но идея важнее конкретной библиотеки.

Зависимые поля

«Если заявитель юрлицо - показать ИНН и КПП» это не три v-if в разных местах, а условие в схеме:

const schema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('person'),  passport: z.string().min(1) }),
  z.object({ type: z.literal('company'), inn: z.string().length(10), kpp: z.string().length(9) }),
]);

Тот же приём, что и с размеченными объединениями в TS: у физлица поля inn нет вообще - ни в типах, ни в отправляемых данных. Никаких пустых строк на всякий случай.

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

Черновик

Длинные формы теряются: закрыл вкладку, отвалился вайфай, случайно нажал «назад». Автосохранение в стор плюс localStorage пишется за вечер, а жалоб снимает много:

watchDebounced(values, (v) => draftStore.save('application', v), { debounce: 800 });

Только не сохраняй туда пароли, коды подтверждения и данные карт. Звучит очевидно ровно до того момента, пока кто-то не сохранит форму целиком одним объектом.

Двойной клик

Две одинаковые заявки в базе - классика. Блокировка кнопки на время запроса это минимум на клиенте, но настоящая защита - идемпотентность на бэке. Разговор про неё лучше провести до начала работ, а не после инцидента.

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

Ссылки