Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Форми й валідація

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

8 питань

Найпростіший підхід - один реактивний об'єкт з усіма полями форми і v-model на кожному полі:

<script setup lang="ts">
import { reactive, ref } from 'vue'

const initial = () => ({ name: '', email: '', role: 'editor', agree: false })

const form = reactive(initial())
const submitting = ref(false)

async function submit() {
  submitting.value = true
  try {
    await api.post('/users', { ...form })
    Object.assign(form, initial())   // скидання
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model.trim="form.name" />
    <input v-model="form.email" type="email" />
    <select v-model="form.role">
      <option value="editor">Редактор</option>
      <option value="admin">Адміністратор</option>
    </select>
    <input v-model="form.agree" type="checkbox" />
    <button :disabled="submitting">Зберегти</button>
  </form>
</template>

reactive чи ref:

reactive({...}) ref({...})
доступ у <script> form.email form.value.email
замінити весь об'єкт ні - лише Object.assign так: form.value = initial()
деструктуризація ламає реактивність теж ламає (form.value - реактивний об'єкт)

ref зручніший, коли форму треба повністю замінити (завантажили дані для редагування). reactive - коротший запис полів.

Модифікатори v-model: .trim прибирає пробели, .number перетворює на число, .lazy оновлює значення на change, а не на кожен введений символ.

Типові помилки:

  • @submit без .prevent - браузер відправляє форму звичайним запитом і перезавантажує сторінку;
  • скидання через form = initial() для reactive - зміниться лише локальна змінна, а шаблон лишиться прив'язаним до старого об'єкта;
  • відправка самого реактивного проксі - бібліотеки зазвичай серіалізують його коректно, але безпечніше передавати звичайну копію ({ ...form } чи toRaw(form));
  • checkbox для масиву: кілька чекбоксів з одним v-model і масивом у стані дають список вибраних значень;
  • немає захисту від подвійного кліку - кнопка має бути вимкнена на час відправки.

Для складних форм (валідація, вкладені масиви, стан «змінено») варто взяти бібліотеку форм, а не винаходити все самому.

Докладніше в документації: Vue: прив'язка полів форми

Коли валідація Laravel не проходить у запиті, що очікує JSON, сервер відповідає статусом 422 і тілом:

{
  "message": "The email has already been taken. (and 1 more error)",
  "errors": {
    "email": ["The email has already been taken."],
    "items.0.qty": ["The items.0.qty field must be at least 1."]
  }
}

Ключі - назви полів, для вкладених даних - через крапку. Значення - масив повідомлень.

Обробка в Vue:

<script setup lang="ts">
import { reactive, ref } from 'vue'
import axios from 'axios'

const form = reactive({ name: '', email: '' })
const errors = ref<Record<string, string[]>>({})

async function submit() {
  errors.value = {}
  try {
    await axios.post('/api/users', form, { headers: { Accept: 'application/json' } })
  } catch (e) {
    if (axios.isAxiosError(e) && e.response?.status === 422) {
      errors.value = e.response.data.errors
      return
    }
    throw e   // інші помилки - далі, до глобального обробника
  }
}
</script>

<template>
  <input v-model="form.email" :aria-invalid="!!errors.email" />
  <p v-if="errors.email" class="error">{{ errors.email[0] }}</p>
</template>

З fetch код схожий, але fetch не кидає виняток на 422 - перевіряти треба response.status самостійно, а тіло читати через await response.json().

Важливі деталі:

  • заголовок Accept: application/json обов'язковий: без нього Laravel вважає запит звичайним переходом форми й відповідає редиректом назад з помилками в сесії, а не JSON;
  • очищайте помилки перед новою відправкою, інакше старі повідомлення лишаються на екрані;
  • прибирайте помилку поля при його зміні - watch на поле чи очищення в @input - користувач бачить, що виправлення прийнято;
  • вкладені ключі (items.0.qty) треба зіставити з полем у списку - зберігайте помилки за тим самим шаблоном назв;
  • 401 і 419 - це не валідація: 401 - не автентифікований, 419 - прострочений CSRF-токен (для SPA на Sanctum спершу потрібен запит до /sanctum/csrf-cookie).

Типізація: опишіть тип відповіді 422 ({ message: string; errors: Record<string, string[]> }) один раз і використовуйте в усіх формах - через спільну функцію чи composable useServerErrors().

Докладніше в документації: Axios: обробка помилок

Доступна форма - та, яку можна заповнити з клавіатури й зрозуміти з екранним читачем. Більшість потрібного дає правильний HTML, а Vue лише має не зламати його.

1. Мітка для кожного поля, пов'язана з полем:

<script setup>
import { useId } from 'vue'
const id = useId()
</script>

<template>
  <label :for="id">Email</label>
  <input :id="id" v-model="form.email" type="email" autocomplete="email" required />
</template>

Плейсхолдер - не мітка: він зникає при введенні й часто має низький контраст. useId() (Vue 3.5) генерує стабільний id, однаковий на сервері й у браузері при SSR.

2. Помилка, пов'язана з полем:

<input
  :id="id"
  v-model="form.email"
  :aria-invalid="errors.email ? 'true' : undefined"
  :aria-describedby="errors.email ? `${id}-error` : undefined"
/>
<p v-if="errors.email" :id="`${id}-error`">{{ errors.email[0] }}</p>
  • aria-invalid повідомляє, що значення некоректне;
  • aria-describedby - екранний читач прочитає текст помилки разом з назвою поля;
  • помилка не повинна передаватися лише кольором (червона рамка) - потрібен текст.

3. Фокус на першому полі з помилкою після відправки:

import { nextTick } from 'vue'

async function onInvalid() {
  await nextTick()   // дочекатися рендеру повідомлень
  document.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}

Без цього користувач клавіатури чи екранного читача натискає «Зберегти» - і нічого не відбувається з його точки зору.

4. Зведення помилок для довгих форм: блок з role="alert" нагорі зі списком помилок-посилань на поля.

Ще кілька правил:

  • <button type="submit"> у <form>, а не div з @click - тоді працює Enter і екранні читачі розуміють, що це форма;
  • групи перемикачів - у <fieldset> з <legend>;
  • autocomplete (email, new-password, one-time-code) - менеджери паролів і мобільні клавіатури;
  • стан відправки - aria-busy чи текст на кнопці, а не лише спінер;
  • не вимикайте кнопку відправки до заповнення всіх полів: користувач не розуміє, чого бракує. Краще дозволити натиснути й показати помилки.

Перевірка: пройти форму лише з клавіатури (Tab, Enter, Space) і запустити axe DevTools чи Lighthouse.

Докладніше в документації: MDN: aria-invalid

Рівень 1 - вбудована валідація браузера: атрибути required, type="email", minlength, pattern, min/max.

<input v-model="form.email" type="email" required />

Безкоштовно й доступно, але повідомлення виглядають по-різному в браузерах, їх важко стилізувати, а складні правила («пароль збігається», «дата закінчення після початку») не виражаються. Атрибут novalidate на формі вимикає перевірку браузера, коли ви робите власну.

Рівень 2 - власна валідація через computed:

const errors = computed(() => ({
  email: /^\S+@\S+$/.test(form.email) ? null : 'Некоректна адреса',
  password: form.password.length >= 8 ? null : 'Щонайменше 8 символів',
}))

Підходить для двох-трьох полів. Далі починаються стани «поле торкнулися», «форму відправляли», асинхронні перевірки - і код розростається.

Рівень 3 - бібліотека й схема. VeeValidate керує станом полів, а Zod описує правила:

import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const schema = toTypedSchema(z.object({
  email: z.string().email('Некоректна адреса'),
  password: z.string().min(8, 'Щонайменше 8 символів'),
}))

const { defineField, errors, handleSubmit, meta } = useForm({ validationSchema: schema })
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')

const onSubmit = handleSubmit(async (values) => {
  await api.post('/register', values)   // values вже типізовані з схеми
})

Бібліотека дає errors, стан dirty/touched/valid (meta), валідацію на blur чи input, відправку лише валідних даних.

Схема - спільна мова: та сама схема Zod типізує дані форми й відповідь, її можна використати й для перевірки відповіді API.

Що варто знати:

  • адаптер @vee-validate/zod оголошує peer-залежність від певної мажорної версії Zod - перевіряйте сумісність, перш ніж оновлювати Zod до нової мажорної версії;
  • клієнтська валідація - лише для зручності. Обійти її - справа однієї секунди в DevTools. Справжня перевірка - на сервері, а помилки 422 від Laravel треба показувати поруч із клієнтськими (setErrors() у VeeValidate);
  • дублювання правил між Laravel і Zod неминуче - тримайте на клієнті прості правила формату, а бізнес-правила (унікальність, ліміти) - на сервері;
  • альтернативи: FormKit (з компонентами полів), Vuelidate, TanStack Form.

Докладніше в документації: VeeValidate: інтеграція з Zod

Поле файлу не працює з v-model - значення <input type="file"> можна лише прочитати:

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'

const file = ref<File | null>(null)
const progress = ref(0)

function onChange(e: Event) {
  file.value = (e.target as HTMLInputElement).files?.[0] ?? null
}

async function upload() {
  if (!file.value) return

  const data = new FormData()
  data.append('avatar', file.value)
  data.append('title', 'Аватар')

  await axios.post('/api/avatar', data, {
    onUploadProgress: (e) => {
      progress.value = e.total ? Math.round((e.loaded / e.total) * 100) : 0
    },
  })
}
</script>

<template>
  <input type="file" accept="image/*" @change="onChange" />
  <progress v-if="progress" :value="progress" max="100" />
  <button @click="upload">Завантажити</button>
</template>

Головні моменти:

  • FormData формує запит multipart/form-data. Заголовок Content-Type не встановлюють вручну - браузер сам додає його з правильним boundary. Ручний Content-Type: multipart/form-data без boundary - сервер не розбере тіло;
  • прогрес завантаження дає XMLHttpRequest (подія upload.onprogress), і саме його використовує axios у браузері. У fetch прогресу відправки немає - лише прогрес отримання відповіді через потоки;
  • PUT/PATCH з файлом у Laravel: PHP не розбирає multipart для цих методів. Відправляйте POST з полем _method=PUT (method spoofing).

Попередній перегляд зображення:

const preview = ref<string | null>(null)
watch(file, (f, _, onCleanup) => {
  if (!f) return
  const url = URL.createObjectURL(f)
  preview.value = url
  onCleanup(() => URL.revokeObjectURL(url))   // звільнити пам'ять
})

Великі файли:

  • скасування - AbortController і signal в опціях axios, щоб користувач міг зупинити завантаження;
  • ліміти: upload_max_filesize і post_max_size у PHP, client_max_body_size у Nginx - помилка 413 чи порожній $request->file() часто означає саме це;
  • пряме завантаження в сховище: сервер видає підписаний URL S3/R2, браузер вантажить файл туди напряму, а застосунку повідомляє лише ключ. PHP не тримає гігабайти в пам'яті й не впирається в тайм-аути;
  • частинами (chunked upload, протокол tus) - для дуже великих файлів і нестабільних з'єднань.

Перевірка на клієнті (accept, розмір файлу) - для зручності; сервер усе одно валідує тип і розмір (mimes, max).

Докладніше в документації: MDN: XMLHttpRequest.upload

У формах повторюється той самий набір: мітка, поле, підказка, помилка, атрибути доступності. Його виносять у компонент поля.

defineModel (Vue 3.4+) - найкоротший спосіб підтримати v-model на власному компоненті:

<!-- TextField.vue -->
<script setup lang="ts">
import { useId } from 'vue'

defineOptions({ inheritAttrs: false })

const model = defineModel<string>({ required: true })
const props = defineProps<{ label: string; error?: string; hint?: string }>()
const id = useId()
</script>

<template>
  <div class="field">
    <label :for="id">{{ props.label }}</label>
    <input
      :id="id"
      v-model="model"
      v-bind="$attrs"
      :aria-invalid="props.error ? 'true' : undefined"
      :aria-describedby="props.error ? `${id}-error` : undefined"
    />
    <p v-if="props.hint && !props.error">{{ props.hint }}</p>
    <p v-if="props.error" :id="`${id}-error`" class="error">{{ props.error }}</p>
  </div>
</template>
<TextField v-model="form.email" label="Email" type="email" autocomplete="email" :error="errors.email?.[0]" />

Що тут важливо:

  • defineModel створює ref, синхронізований з батьком: читання - значення modelValue, запис - подія update:modelValue. Без нього довелося б оголошувати проп і подію вручну;
  • inheritAttrs: false + v-bind="$attrs" - атрибути, передані компоненту (type, autocomplete, placeholder, required), потрапляють на <input>, а не на обгортку div. Інакше type="email" опиниться на div і не працюватиме;
  • модифікатори теж підтримуються: const [model, modifiers] = defineModel({ set(v) { return modifiers.trim ? v.trim() : v } });
  • кілька моделей на одному компоненті: defineModel('from') і defineModel('to') для діапазону дат - v-model:from і v-model:to.

Типові помилки:

  • мутувати проп напряму (props.modelValue = ...) - Vue попередить, а батько не дізнається про зміну;
  • локальна копія значення без синхронізації - поле перестає реагувати на скидання форми батьком;
  • об'єкт у defineModel і зміна його властивостей (model.value.city = ...) мутує об'єкт батька в обхід події - для об'єктів присвоюйте нове значення (model.value = { ...model.value, city }).

Межа абстракції: компонент поля відповідає за розмітку й доступність, а не за валідацію. Правила й стан форми лишаються у формі чи бібліотеці валідації - тоді той самий TextField працює з будь-яким підходом.

Докладніше в документації: Vue: v-model на компонентах

Задача: при реєстрації показати «цей email уже зайнятий» ще до відправки форми. Наївне рішення - запит на кожен введений символ - створює три проблеми:

  • навантаження: 20 символів - 20 запитів;
  • гонка відповідей: відповідь на olena@ може прийти пізніше за відповідь на olena@example.com, і на екрані залишиться неправильний результат;
  • перевірка незавершеного значення - марна робота.

Рішення - затримка + скасування + перевірка формату:

import { ref, watch, onWatcherCleanup } from 'vue'

const email = ref('')
const status = ref<'idle' | 'checking' | 'free' | 'taken' | 'error'>('idle')

watch(email, (value) => {
  if (!/^\S+@\S+\.\S+$/.test(value)) {
    status.value = 'idle'
    return
  }

  const controller = new AbortController()
  const timer = setTimeout(async () => {
    status.value = 'checking'
    try {
      const res = await fetch(`/api/email-available?email=${encodeURIComponent(value)}`, {
        signal: controller.signal,
        headers: { Accept: 'application/json' },
      })
      status.value = (await res.json()).available ? 'free' : 'taken'
    } catch (e) {
      if ((e as Error).name !== 'AbortError') status.value = 'error'
    }
  }, 400)

  onWatcherCleanup(() => {      // Vue 3.5+: викликається перед наступним запуском
    clearTimeout(timer)         // ще не почали - не починаємо
    controller.abort()          // уже почали - скасовуємо запит
  })
})

Як це працює: кожна зміна значення спершу «прибирає» попередній запуск - скасовує таймер і запит. Тому відповідь на застаріле значення або не надійде, або буде відкинута, і гонки не буде. До Vue 3.5 те саме робить третій аргумент колбека watch - onCleanup.

Що врахувати:

  • 400-500 мс - розумна затримка: користувач устигає дописати слово, і відповідь усе одно здається миттєвою;
  • AbortError - не помилка, його ігнорують;
  • перевірка на клієнті - підказка, а не гарантія: між перевіркою й відправкою адресу може зайняти інший користувач. Унікальність гарантує правило unique на сервері й унікальний індекс у базі;
  • перебір облікових записів: ендпойнт «чи зайнятий email» розкриває, хто зареєстрований. Обмежте частоту запитів (throttle) і подумайте, чи прийнятне це розкриття для вашого продукту;
  • у VeeValidate асинхронне правило в схемі запускається при кожній валідації поля - затримку й скасування там теж треба додати, бібліотека не робить їх сама.

Той самий прийом працює для пошуку з автодоповненням, перевірки промокоду, розрахунку вартості доставки.

Докладніше в документації: Vue: очищення побічних ефектів у watch

Задача: користувач півгодини заповнював форму, випадково клікнув посилання - і все втратив. Потрібні дві речі: знати, чи змінено форму, і перехопити вихід.

1. Чи змінена форма - порівняння з початковим знімком:

import { computed, reactive, ref } from 'vue'

const snapshot = ref(JSON.stringify(initialData))
const form = reactive(structuredClone(initialData))

const isDirty = computed(() => JSON.stringify(form) !== snapshot.value)

function markSaved() {
  snapshot.value = JSON.stringify(form)   // після успішного збереження
}

Порівняння зі знімком краще за прапорець «щось змінювали»: якщо користувач змінив поле й повернув старе значення, форма знову вважається чистою. Для великих форм серіалізація на кожну зміну може бути відчутною - тоді порівнюють лише змінені поля чи беруть meta.dirty з бібліотеки форм.

2. Перехід усередині SPA - guard Vue Router:

import { onBeforeRouteLeave } from 'vue-router'

onBeforeRouteLeave(() => {
  if (isDirty.value && !window.confirm('Є незбережені зміни. Вийти?')) {
    return false   // скасувати навігацію
  }
})

Замість confirm можна показати власне модальне вікно - guard може бути асинхронним і повернути false після відповіді користувача.

3. Закриття вкладки, перезавантаження, зовнішнє посилання - роутер про це не знає, потрібна подія браузера:

function onBeforeUnload(e: BeforeUnloadEvent) {
  if (!isDirty.value) return
  e.preventDefault()
  e.returnValue = ''   // для старіших браузерів
}

onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))

Браузер покаже власне стандартне повідомлення - свій текст задати неможливо, це захист від зловживань.

Підводні камені:

  • після збереження не оновили знімок - користувач щойно зберіг, а при виході отримує попередження;
  • редирект після збереження спрацьовує раніше за оновлення стану - guard блокує власний перехід застосунку. Оновлюйте знімок до навігації;
  • beforeunload постійно підписаний заважає браузеру використовувати back/forward cache - вішайте обробник лише коли форма змінена;
  • дані з сервера підвантажилися після створення знімка - форма одразу «брудна». Знімок робиться після завантаження;
  • не зловживайте: попередження для пошукового поля чи фільтрів дратує. Лише для форм, де втрата справді болить; для довгих форм краще чернетки в localStorage чи автозбереження.

Докладніше в документації: Vue Router: навігаційні guards у Composition API