Архитектура форм Vue 3 для бизнес-приложений: переиспользуемые паттерны
Архитектура форм во Vue 3 для бизнес-приложений: переиспользуемые компоненты полей, понятные правила валидации и практические способы показывать ошибки сервера рядом с нужным вводом.

Почему код форм ломается в реальных бизнес-приложениях
Форма в бизнес-приложении редко остаётся маленькой. Сначала это «всего пару полей», а затем она разрастается до десятков полей, условных секций, прав и правил, которые должны соответствовать логике бэкенда. После нескольких изменений в продукте форма всё ещё работает, но код начинает казаться хрупким.
Архитектура форм во Vue 3 важна, потому что именно в формах скапливаются «быстрые правки»: ещё один watcher, ещё один особый случай, ещё один скопированный компонент. Сегодня это работает, но с течением времени становится труднее доверять коду и вносить изменения.
Признаки знакомы: одинаковое поведение ввода повторяется на разных страницах (ярлыки, форматирование, отметки обязательных полей, подсказки), ошибки показываются непоследовательно, правила валидации разбросаны по компонентам, а ошибки сервера сводятся к общей тост-нотификации, которая не говорит пользователю, что нужно исправить.
Эти несоответствия — не просто стиль кода. Они превращаются в UX-проблемы: пользователи повторно отправляют формы, количество тикетов в поддержку растёт, а команды боятся трогать формы из-за риска поломать скрытый кейс.
Хорошая настройка делает формы «скучными» в лучшем смысле. С предсказуемой структурой вы можете добавлять поля, менять правила и обрабатывать ответы сервера, не переписывая всё подряд.
Вам нужна система форм, которая даёт повторное использование (поле ведёт себя одинаково везде), понятность (правила и обработка ошибок легко просмотреть), предсказуемое поведение (touched, dirty, reset, submit) и лучшие подсказки (серверные ошибки появляются рядом с теми полями, которые требуют внимания). Ниже — паттерны для переиспользуемых компонентов полей, читаемой валидации и сопоставления серверных ошибок с конкретными полями.
Простая модель мышления о структуре формы
Форма, которая выдерживает время, — это небольшая система с ясными частями, а не набор полей.
Думайте о четырёх слоях, которые общаются в одном направлении: UI собирает ввод, состояние формы его хранит, валидация объясняет, что не так, а слой API загружает и сохраняет данные.
Четыре слоя (и за что каждый отвечает)
- Field UI component: отрисовывает ввод, метку, подсказку и текст ошибки. Генерирует события изменения значения.
- Form state: хранит значения и ошибки (плюс флаги touched и dirty).
- Validation rules: чистые функции, которые читают значения и возвращают сообщения об ошибках.
- API calls: загружают начальные данные, отправляют изменения и переводят ответы сервера в ошибки по полям.
Это разделение держит изменения локализованными. Когда приходит новое требование, вы обновляете один слой, не ломая остальные.
Что относится к полю, а что к родительской форме
Переиспользуемый компонент поля должен быть простым. Он не должен знать про ваш API, модель данных или правила валидации. Он должен только отображать значение и показывать ошибку.
Родительская форма координирует всё остальное: какие поля существуют, где хранятся значения, когда валидировать и как отправлять.
Простое правило помогает: если логика зависит от других полей (например, «Штат» обязателен только когда «Страна» — США), держите её в родительской форме или в слое валидации, а не внутри компонента поля.
Когда добавление нового поля действительно мало затратное, обычно вы меняете только дефолты или схему, разметку, где поле размещено, и правила валидации поля. Если добавление одного ввода заставляет менять несвязанные компоненты, границы размыты.
Переиспользуемые компоненты полей: что стандартизовать
Когда формы растут, самый быстрый выигрыш — перестать строить каждое поле как уникальное. Компоненты полей должны быть предсказуемыми. Это делает их быстрыми в использовании и простыми для ревью.
Практичный набор блоков:
- BaseField: обёртка для метки, подсказки, текста ошибки, отступов и атрибутов доступности.
- Input components: TextInput, SelectInput, DateInput, Checkbox и т. д. Каждый фокусируется на контроле.
- FormSection: группирует связанные поля с заголовком, короткой подсказкой и единым отступом.
Для пропсов держите небольшой набор и соблюдайте его везде. Переименование пропса в 40 формах доставляет боль.
Обычно сразу окупаются такие пропсы:
modelValueиupdate:modelValueдляv-modellabelrequireddisablederror(одно сообщение или массив, если предпочитаете)hint
Слоты дают гибкость без ломки согласованности. Сохраняйте расположение BaseField стабильным, но позволяйте небольшие вариации, например действие справа («Отправить код») или иконку слева. Если вариация встречается дважды, сделайте её слотом вместо форка компонента.
Стандартизируйте порядок рендера (метка, контрол, подсказка, ошибка). Пользователи просматривают быстрее, тесты проще, и сопоставление ошибок сервера становится очевидным, потому что у каждого поля одно очевидное место для сообщения.
Состояние формы: values, touched, dirty и reset
Большинство ошибок в бизнес-формах связаны не с вводами, а с раскиданным состоянием: значения в одном месте, ошибки в другом и кнопка сброса, которая работает наполовину. Чистая архитектура формы во Vue 3 начинается с одной согласованной формы состояния.
Сначала выберите схему именования ключей полей и придерживайтесь её. Самое простое правило: ключ поля равен ключу в API-пейлоуде. Если сервер ожидает first_name, ключ формы должен быть first_name. Это небольшое решение сильно упрощает валидацию, сохранение и сопоставление серверных ошибок.
Храните состояние формы в одном месте (композабл, Pinia-стор или родительский компонент) и пусть каждое поле читает и записывает через это состояние. Плоская структура работает для большинства экранов. Вложенность делайте только когда API действительно вложен.
const state = reactive({
values: { first_name: '', last_name: '', email: '' },
touched: { first_name: false, last_name: false, email: false },
dirty: { first_name: false, last_name: false, email: false },
errors: { first_name: '', last_name: '', email: '' },
defaults: { first_name: '', last_name: '', email: '' }
})
Практичный взгляд на флаги:
touched: взаимодействовал ли пользователь с этим полем?dirty: отличается ли значение от дефолтного (или от последнего сохранённого) значения?errors: какое сообщение должен видеть пользователь прямо сейчас?defaults: к чему мы сбрасываемся?
Поведение reset должно быть предсказуемым. Когда вы загружаете существующую запись, установите и values, и defaults из одного источника. Тогда reset() может скопировать defaults обратно в values, очистить touched, очистить dirty и очистить errors.
Пример: форма профиля клиента загружает email с сервера. Если пользователь его правит, dirty.email становится true. Если нажать Reset, email вернётся к загруженному значению (не к пустой строке), и экран снова будет чистым.
Правила валидации, которые остаются читаемыми
Читабельная валидация — это не столько про библиотеку, сколько про то, как вы выражаете правила. Если можно быстро взглянуть на поле и понять его правила за пару секунд, код формы остаётся поддерживаемым.
Выберите стиль правил и придерживайтесь его
Большинство команд выбирают один из подходов:
- Per-field rules: правила живут рядом с использованием поля. Легко сканировать, отлично для маленьких и средних форм.
- Schema-based rules: правила в одном объекте или файле. Подходит, когда многие экраны переиспользуют одну модель.
- Hybrid: простые правила рядом с полями, общие или сложные правила в центральной схеме.
Что бы вы ни выбрали, держите имена правил и сообщения предсказуемыми. Несколько общих правил (required, length, format, range) предпочтительнее длинного списка одноразовых хелперов.
Пишите правила как простые предложения
Хорошее правило читается как предложение: «Email обязателен и должен выглядеть как email.» Избегайте хитрых однострочников, которые скрывают намерение.
Для большинства бизнес-форм возврат одного сообщения на поле (первая ошибка) сохраняет UI спокойным и помогает пользователям быстрее исправлять ошибки.
Распространённые правила, которые остаются удобными:
- Required только когда поле действительно обязательно.
- Length с реальными числами (например, 2–50 символов).
- Format для email, телефона, ZIP-кода, без чрезмерно строгих регексов, отбрасывающих реальные вводы.
- Range вроде «дата не в будущем» или «количество от 1 до 999.»
Делайте асинхронные проверки очевидными
Асинхронная валидация (например, «имя пользователя занято») запутывает, если запускается молча.
Запускайте проверки на blur или после короткой паузы, показывайте явное состояние «Проверка...», и отменяйте или игнорируйте устаревшие запросы, когда пользователь продолжает вводить.
Решите, когда запускать валидацию
Время имеет такое же значение, как и правила. Дружественная настройка:
- On change для полей, где полезна живая обратная связь (например, сила пароля), но делайте это аккуратно.
- On blur для большинства полей, чтобы пользователи могли печатать без постоянных ошибок.
- On submit для полной проверки формы как последнего защитного слоя.
Сопоставление серверных ошибок с нужным полем
Клиентские проверки — это лишь половина истории. В бизнес-приложениях сервер отклоняет сохранения по причинам, о которых браузер не знает: дубликаты, проверки прав, устаревшие данные, изменения состояния и прочее. Хороший UX формы превращает этот ответ в понятные сообщения рядом с нужными полями.
Нормализуйте ошибки в одну внутреннюю форму
Бэкенды редко сходятся на формате ошибок. Кто-то возвращает один объект, кто-то — списки, кто-то — вложенные карты с ключами по именам полей. Преобразуйте любой полученный формат в одну внутреннюю форму, которую может отрисовать ваша форма.
// what your form code consumes
{
fieldErrors: { "email": ["Already taken"], "address.street": ["Required"] },
formErrors: ["You do not have permission to edit this customer"]
}
Держите несколько правил:
- Храните ошибки по полям как массивы (даже если сообщений одно).
- Преобразуйте разные стили путей к единому (точечная нотация удобна:
address.street). - Держите общие (не по полю) ошибки отдельно в
formErrors. - Сохраняйте сыой пейлоуд сервера для логов, но не рендерьте его напрямую.
Сопоставляйте серверные пути с ключами полей
Сложность — согласовать представление «пути» сервера с ключами полей формы. Решите ключ для каждого поля компонента (например, email, profile.phone, contacts.0.type) и придерживайтесь его.
Затем напишите небольшой маппер, который обработает распространённые случаи:
address.street(точечная нотация)address[0].street(скобочная нотация для массивов)/address/street(JSON Pointer)
После нормализации <Field name="address.street" /> должен уметь читать fieldErrors["address.street"] без специальных случаев.
Поддерживайте алиасы при необходимости. Если бэкенд возвращает customer_email, а UI использует email, держите маппинг вроде { customer_email: "email" } при нормализации.
Ошибки по полям, ошибки на уровне формы и установка фокуса
Не каждая ошибка относится к одному полю. Если сервер говорит «Достигнут лимит плана» или «Требуется оплата», покажите это над формой как сообщение уровня формы.
Для ошибок по полям показывайте сообщение рядом с вводом и направляйте пользователя к первой проблеме:
- После установки серверных ошибок найдите первый ключ в
fieldErrors, который рендерится в форме. - Прокрутите к нему и поставьте фокус (используя ref для каждого поля и
nextTick). - Очищайте серверные ошибки поля, когда пользователь снова редактирует это поле.
Шаг за шагом: как собрать архитектуру вместе
Формы остаются спокойными, когда вы рано решаете, что относится к состоянию формы, UI, валидации и API, а затем связываете их несколькими маленькими функциями.
Последовательность, которая работает для большинства бизнес-приложений:
- Начните с одной модели формы и стабильных ключей полей. Эти ключи становятся контрактом между компонентами, валидаторами и серверными ошибками.
- Создайте один BaseField-обёртку для метки, подсказки, отметки обязательности и отображения ошибок. Держите компоненты ввода маленькими и единообразными.
- Добавьте слой валидации, который может запускаться по полю и валидировать всё при отправке.
- Отправляйте на API. Если он вернёт ошибку, переведите серверные ошибки в
{ [fieldKey]: message }, чтобы правильное поле показало правильное сообщение. - Держите обработку успеха отдельно (reset, toast, навигация), чтобы это не утекало в компоненты и валидаторы.
Простая отправная точка для состояния:
const values = reactive({ email: '', name: '', phone: '' })
const touched = reactive({ email: false, name: false, phone: false })
const errors = reactive({}) // { email: '...', name: '...' }
Ваш BaseField получает label, error и, возможно, touched, и рендерит сообщение в одном месте. Каждый компонент ввода заботится только о биндинге и эмитах обновлений.
Для валидации держите правила рядом с моделью, используя те же ключи:
const rules = {
email: v => (!v ? 'Email is required' : /@/.test(v) ? '' : 'Enter a valid email'),
name: v => (v.length < 2 ? 'Name is too short' : ''),
}
function validateAll() {
Object.keys(rules).forEach(k => {
const msg = rules[k](values[k])
if (msg) errors[k] = msg
else delete errors[k]
touched[k] = true
})
return Object.keys(errors).length === 0
}
Когда сервер отвечает ошибками, мапьте их теми же ключами. Если API вернёт { "field": "email", "message": "Already taken" }, установите errors.email = 'Already taken' и пометьте его как touched. Если ошибка глобальная (например, "permission denied"), покажите её над формой.
Пример сценария: редактирование профиля клиента
Представьте внутренний экран админа, где сотрудник поддержки редактирует профиль клиента. Форма из четырёх полей: name, email, phone и role (Customer, Manager, Admin). Она небольшая, но показывает типичные проблемы.
Клиентские правила должны быть понятны:
- Name: обязателен, минимальная длина.
- Email: обязателен, корректный формат.
- Phone: опционально, но если заполнено — должно соответствовать принятому формату.
- Role: обязателен и иногда условен (только пользователи с правами могут назначать Admin).
Унифицированный контракт компонентов помогает: каждое поле получает текущее значение, текст ошибки (если есть) и пару булевых флагов вроде touched и disabled. Подписи, отметки обязательности, отступы и стили ошибок не должны изобретаться на каждом экране.
Теперь UX-поток. Агент редактирует email, уходит из поля, и под Email появляется встроенное сообщение, если формат неверен. Он исправляет, нажимает Save, и сервер отвечает:
- email already exists: показывает под Email и фокусирует это поле.
- phone invalid: показывает под Phone.
- permission denied: показывает одно сообщение уровня формы вверху.
Если вы держите ошибки по ключам полей (email, phone, role), маппинг прост. Ошибки по полям попадают рядом с вводами, ошибки уровня формы — в отдельную область.
Распространённые ошибки и как их избежать
Держите логику в одном месте
Копирование правил валидации по экранам кажется быстрым, пока политики не поменяются (правила паролей, обязательные налоговые ID, разрешённые домены). Централизуйте правила (схема, файл с правилами, общая функция), и формы будут пользоваться одним набором правил.
Также избегайте ситуаций, когда низкоуровневые Inputs делают слишком много. Если ваш <TextField> знает, как вызывать API, ретраиться при ошибках и парсить пейлоуды серверных ошибок, он перестаёт быть переиспользуемым. Поля должны только рендерить, испускать изменения значений и показывать ошибки. Вызовы API и логика маппинга — в контейнере формы или в композабле.
Симптомы смешения ответственностей:
- Одно и то же сообщение валидации написано в нескольких местах.
- Компонент поля импортирует API-клиент.
- Смена одного эндпойнта ломает несколько несвязанных форм.
- Тесты требуют монтирования половины приложения, чтобы проверить один ввод.
UX и ловушки доступности
Одна баннерная ошибка «Что-то пошло не так» — недостаточно. Людям нужно знать, какое поле неверно и что делать дальше. Используйте баннеры для глобальных сбоев (сеть, отказ в правах), а серверные ошибки мапьте к конкретным полям, чтобы пользователи могли быстро исправить их.
Проблемы со статусами загрузки и двойной отправкой создают запутанные состояния. При отправке блокируйте кнопку submit, делайте неактивными поля, которые не должны меняться во время сохранения, и показывайте явный индикатор занятости. Убедитесь, что reset и cancel корректно восстанавливают форму.
Основы доступности легко упустить с кастомными компонентами. Несколько простых правил спасут много времени:
- У каждого поля есть видимая метка (не только placeholder).
- Ошибки связаны с полями через aria-атрибуты.
- Фокус перемещается к первому неверному полю после отправки.
- Неактивные поля действительно неинтерактивны и корректно объявляются.
- Навигация с клавиатуры работает полностью.
Быстрый чеклист и следующие шаги
Перед релизом новой формы выполните чеклист. Он ловит мелкие пробелы, которые потом превращаются в тикеты поддержки.
- У каждого поля есть стабильный ключ, который совпадает с полезной нагрузкой и ответом сервера (включая вложенные пути типа
billing.address.zip)? - Можно ли отрисовать любое поле, используя один согласованный API компонента поля (value in, events out, error и hint in)?
- При отправке вы валидируете, блокируете повторные отправки и ставите фокус на первое неверное поле, чтобы пользователь знал, с чего начать?
- Можете ли вы показывать ошибки в нужном месте: по полю рядом с вводом и на уровне формы — общее сообщение при необходимости?
- После успеха вы корректно сбрасываете состояние (values, touched, dirty), чтобы следующий редакт начался чисто?
Если на один вопрос ответ «нет», почините это в первую очередь. Самая распространённая боль — несоответствие: имена полей расходятся с API или серверные ошибки приходят в форме, которую UI не может разместить.
Если вы строите внутренние инструменты и хотите двигаться быстрее, AppMaster (appmaster.io) следует тем же принципам: держите UI полей согласованным, централизуйте правила и workflow-ы, и делайте так, чтобы серверные ответы появлялись там, где пользователи могут на них отреагировать.
Вопросы и ответы
Стандартизируйте поля, когда вы замечаете одинаковые подписи, подсказки, отметки обязательных полей, отступы и оформление ошибок на нескольких страницах. Если одна «маленькая» правка требует изменения многих файлов, общий BaseField и несколько согласованных компонентов ввода быстро окупают себя.
Делайте компонент поля «тупым»: он отрисовывает подпись, контрол, подсказку и ошибку и испускает события обновления значения. Междуполевую логику, условные правила и всё, что зависит от других полей, держите в родительской форме или в слое валидации — так поле останется переиспользуемым.
По умолчанию используйте устойчивые ключи, совпадающие с полезной нагрузкой API, например first_name или billing.address.zip. Это упрощает валидацию и сопоставление серверных ошибок, потому что вам не нужно постоянно переводить имена между слоями.
Простой дефолт — один объект состояния, содержащий values, errors, touched, dirty и defaults. Когда всё читает и пишет через одну и ту же структуру, поведение сброса и отправки становится предсказуемым и вы избегаете ошибок «полусброса».
При загрузке записи установите и values, и defaults из одних и тех же данных. Тогда reset() должен скопировать defaults обратно в values и очистить touched, dirty и errors, чтобы интерфейс выглядел чисто и соответствовал последнему состоянию на сервере.
Начинайте с правил как простых функций, ключей по тем же именам полей, что и в состоянии формы. Возвращайте одно понятное сообщение на поле (первую ошибку), чтобы интерфейс оставался спокойным, а пользователю было ясно, что исправлять.
Валидируйте большинство полей на blur, а при отправке валидируйте всё как финальную проверку. Используйте валидацию при изменении только там, где это действительно помогает (например, индикатор сложности пароля), чтобы пользователи не получали ошибки во время ввода.
Проводите асинхронные проверки на blur или после короткой дебаунс-задержки и показывайте явный статус «проверка». Отменяйте или игнорируйте устаревшие запросы, чтобы медленные ответы не перезаписывали более новые вводы и не создавали запутанных ошибок.
Нормализуйте любой формат бэкенда в единую внутреннюю форму, например { fieldErrors: { key: [messages] }, formErrors: [messages] }. Используйте один стиль путей (точечная нотация работает хорошо), чтобы поле address.street всегда могло читать fieldErrors['address.street'] без специальных случаев.
Показывайте общие ошибки над формой, а сообщения по полям — рядом с соответствующим вводом. После неудачной отправки фокусируйте первое поле с ошибкой и очищайте серверную ошибку этого поля, как только пользователь начнёт его редактировать снова.


