Питання на співбесіді: Форми й Actions
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Неконтрольоване поле зберігає значення саме, у DOM. React лише задає початкове значення:
<input name="title" defaultValue="Чернетка" />
Значення читають у момент відправки - з FormData чи через ref.
Контрольоване поле - значення живе в стані React, а поле лише показує його:
const [title, setTitle] = useState('');
<input value={title} onChange={(e) => setTitle(e.target.value)} />
Кожне натискання клавіші - onChange → новий стан → новий рендер з новим value.
Коли контрольоване:
- значення потрібне під час введення: лічильник символів, пошук «на льоту», валідація в реальному часі;
- поля залежать одне від одного (вибір країни змінює список міст);
- значення треба форматувати чи обмежувати (лише цифри, верхній регістр);
- кнопка «Зберегти» активна лише при зміненій формі.
Коли неконтрольоване:
- значення потрібне лише при відправці - звичайна форма з
<form action>чиonSubmitіFormData; - великі форми, де рендер на кожну літеру помітно гальмує;
<input type="file">- завжди неконтрольоване: значення файлового поля задати з коду не можна.
Правила, які не можна порушувати:
- поле не може бути одночасно контрольованим і неконтрольованим, і не повинне перемикатися між режимами. Типова причина -
value={user.name}, деnameспершуundefined: поле стартує неконтрольованим, а потім стає контрольованим. Початкове значення - порожній рядок'', а неnullчиundefined; - для чекбоксів -
checked/defaultChecked, а неvalue.
Бібліотеки форм (React Hook Form) за замовчуванням працюють з неконтрольованими полями саме заради продуктивності, а стан помилок і «брудності» ведуть окремо.
Докладніше в документації: input: керування полем через стан
Класичний спосіб - onSubmit:
function SearchForm() {
function handleSubmit(e) {
e.preventDefault(); // інакше браузер перезавантажить сторінку
const data = new FormData(e.currentTarget);
search(data.get('q'));
}
return (
<form onSubmit={handleSubmit}>
<input name="q" />
<button type="submit">Шукати</button>
</form>
);
}
React 19 - функція в пропі action:
function SearchForm() {
async function searchAction(formData) {
await search(formData.get('q'));
}
return (
<form action={searchAction}>
<input name="q" />
<button type="submit">Шукати</button>
</form>
);
}
Чим action відрізняється:
preventDefault()не потрібен - React сам перехоплює відправку;- функція отримує
FormDataодразу аргументом; - виконується всередині Transition - на ній працюють
useFormStatus(стан відправки для кнопки),useActionState(результат дії) іuseOptimistic; - після успішного виконання неконтрольовані поля форми скидаються автоматично. Якщо поле має лишитися заповненим (пошук), значення треба зберігати в стані чи повертати з дії;
- метод HTTP завжди POST, незалежно від атрибута
method; - різні кнопки можуть викликати різні дії через
formAction:
<button type="submit">Опублікувати</button>
<button formAction={saveDraft}>Зберегти чернетку</button>
Коли все ж onSubmit: складна клієнтська валідація перед відправкою, бібліотеки форм зі своїм handleSubmit (React Hook Form), потреба повністю контролювати процес.
Пастка для обох способів: кнопка без type усередині форми - це type="submit". Кнопка «Скасувати» чи «Додати рядок» без type="button" відправить форму.
Поле не реагує на введення. Причина - value без onChange:
<input value={title} /> // поле лише для читання
Контрольоване поле завжди показує те, що передано у value. Користувач натискає клавішу, браузер змінює поле, React повертає старе значення. У консолі - попередження: «You provided a value prop to a form field without an onChange handler».
Варіанти виправлення:
- потрібне лише початкове значення -
defaultValue={title}; - поле має керуватися станом - додати
onChange={(e) => setTitle(e.target.value)}; - поле справді лише для читання - явно
readOnly.
Курсор стрибає в кінець чи на початок. Типові причини:
1. Асинхронне оновлення стану:
onChange={async (e) => {
await validate(e.target.value);
setTitle(e.target.value); // запізно - поле вже перемальовано зі старим значенням
}}
Стан контрольованого поля треба оновлювати синхронно в обробнику, а асинхронну перевірку робити окремо.
2. Поле перестворюється на кожен рендер:
- компонент поля оголошено всередині іншого компонента - на кожен рендер це новий тип компонента, React знищує старий
<input>і створює новий; - змінюється
keyполя (наприклад,key={Math.random()}).
function Form() {
// помилка: новий компонент на кожен рендер
const Field = (props) => <input {...props} />;
return <Field value={title} onChange={...} />;
}
Компоненти оголошують на верхньому рівні модуля.
3. Перетворення значення на ходу (setTitle(e.target.value.toUpperCase())) іноді збиває позицію курсора в деяких браузерах. Для форматування (телефон, сума) краще форматувати при виході з поля чи спеціальні бібліотеки масок.
value={null} чи undefined робить поле неконтрольованим - а коли значення з'явиться, React попередить про перемикання режиму. Початкове значення - ''.
Доступна форма - та, якою можна користуватися з клавіатури й зчитувачем екрана. Основа - правильні зв'язки між полями, мітками й повідомленнями.
Мітка для кожного поля. placeholder мітку не замінює: він зникає при введенні й погано читається.
<label htmlFor="email">Електронна адреса</label>
<input id="email" name="email" type="email" />
У JSX - htmlFor, а не for.
Унікальні id - через useId. Жорсткий id="email" ламається, якщо компонент поля використано на сторінці двічі. useId генерує стабільний унікальний id, однаковий на сервері й клієнті (без розбіжностей гідратації):
function TextField({ label, error, ...props }) {
const id = useId();
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && <p id={errorId} role="alert">{error}</p>}
</div>
);
}
aria-invalid- зчитувач оголосить, що поле з помилкою;aria-describedby- текст помилки прочитається разом з полем;role="alert"- повідомлення оголошується одразу при появі.
useId - не для ключів у списках: ключі мають походити з даних.
Інші правила:
- групи радіокнопок і чекбоксів - у
<fieldset>з<legend>; - фокус на першій помилці після невдалої відправки (React Hook Form робить це сам -
shouldFocusError); - не вимикати кнопку відправки «доки форма невалідна» - користувач не розуміє, що не так. Краще дозволити відправку й показати помилки;
- правильні типи полів (
type="email",inputMode="numeric",autoComplete="email") - правильна клавіатура на телефоні й автозаповнення; - стан відправки оголошувати текстом («Зберігаємо...»), а не лише спінером.
Перевірка: пройти форму лише клавіатурою (Tab, Enter, пробіл) і з увімкненим VoiceOver/NVDA.
useActionState (React 19) - стан, що оновлюється результатом дії (Action). Зручно для форм: дія відправляє дані, а повернене значення стає новим станом - повідомленням про успіх чи помилками.
import { useActionState } from 'react';
async function subscribe(previousState, formData) {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
if (response.status === 422) {
const { errors } = await response.json();
return { errors, email: formData.get('email') };
}
return { success: true };
}
function SubscribeForm() {
const [state, formAction, isPending] = useActionState(subscribe, { errors: {} });
if (state.success) return <p>Дякуємо за підписку!</p>;
return (
<form action={formAction}>
<input name="email" type="email" defaultValue={state.email} />
{state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}
<button disabled={isPending}>{isPending ? 'Надсилаємо...' : 'Підписатися'}</button>
</form>
);
}
Як працює:
useActionState(reducerAction, initialState)повертає[state, dispatchAction, isPending];reducerAction(previousState, payload)- як редюсер уuseReducer, але може бути асинхронним і мати побічні ефекти. Для формиpayload- цеFormData;isPending- чи виконується дія;- кілька викликів ставляться в чергу й виконуються послідовно: кожен отримує результат попереднього. Подвійне натискання не дасть двох паралельних запитів у довільному порядку;
dispatchActionтреба викликати з дії: передати в<form action>чи<button formAction>, або обгорнути вstartTransition.
Пастки:
- форма скидається після успішної дії - неконтрольовані поля очищуються. Щоб не губити введене при помилці, повертайте значення в стані (
defaultValue={state.email}, як у прикладі); - кинутий виняток у дії скасовує всі дії в черзі й показує найближчий Error Boundary. Очікувані помилки (валідація) - повертати як стан, а не кидати;
- початковий стан і тип результату мають збігатися - інакше TypeScript скаржиться на невідповідність типів.
Server Functions: з Next.js чи іншим RSC-фреймворком дія може бути серверною функцією ('use server') - тоді форма працює навіть до завантаження JavaScript (третій аргумент permalink).
useFormStatus (з react-dom) повертає стан відправки батьківської форми: pending, data (FormData, що відправляється), method, action.
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Зберігаємо...' : children}
</button>
);
}
function ProfileForm() {
return (
<form action={updateProfile}>
<input name="name" />
<SubmitButton>Зберегти</SubmitButton>
</form>
);
}
Кнопку з власним станом можна використовувати в будь-якій формі - без передачі isSubmitting через props.
Чому pending завжди false. Хук бачить лише форму, всередині якої рендериться компонент. Якщо викликати його в тому самому компоненті, що рендерить <form>, батьківської форми для хука немає:
function ProfileForm() {
const { pending } = useFormStatus(); // завжди false: форма нижче, а не вище
return <form action={updateProfile}>...</form>;
}
Тому стан відправки виносять в окремий дочірній компонент (кнопка, індикатор), а в компоненті з формою для цього є isPending з useActionState.
Інші умови роботи:
- форма має відправлятися через дію (
action={функція}). ЗonSubmitчиaction="/url"хук нічого не знає про відправку; - поле
dataдає змогу показати, що саме відправляється («Додаємо "Купити молоко"...»).
Навіщо блокувати кнопку на час відправки: від подвійних відправок (два замовлення, два коментарі). Але це UX-захист - сервер теж має бути готовим до повторів (ключі ідемпотентності, унікальні обмеження).
Доступність: aria-busy і текстова зміна на кнопці, щоб стан відправки був зрозумілий не лише візуально.
Для простих форм вистачає нативних засобів React 19 (action, useActionState). Коли полів багато, є валідація з залежностями між полями, динамічні списки полів і складний UX помилок, - зручніше бібліотека.
React Hook Form керує формою через неконтрольовані поля й refs: введення не викликає рендер усієї форми. Стан помилок, «брудності», відправки - окремо.
Zod описує схему даних, з якої виходить і валідація, і тип TypeScript.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.email('Некоректна адреса'),
password: z.string().min(8, 'Щонайменше 8 символів'),
age: z.coerce.number().int().min(18, 'Лише для повнолітніх'),
});
type FormValues = z.infer<typeof schema>;
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (values: FormValues) => {
await api.signUp(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="number" {...register('age')} />
<button disabled={isSubmitting}>Зареєструватися</button>
</form>
);
}
Що дає поєднання:
- одна схема - і правила, і тип даних (
z.infer); схему можна використати повторно на сервері (Node) чи для перевірки відповіді API; handleSubmitвикликаєonSubmitлише з валідними даними, а при помилках ставить фокус на перше невалідне поле;- режими перевірки:
mode: 'onSubmit'(за замовчуванням),'onBlur','onChange','onTouched'.
Що варто знати:
- у Zod 4 формати рядків стали окремими функціями:
z.email()замість старогоz.string().email()(той лишився, але застарів); - значення полів - рядки: для чисел -
z.coerce.number()чиvalueAsNumberуregister; - клієнтська валідація - лише зручність. Сервер (Laravel Form Request) перевіряє все заново, а його помилки треба показати у формі через
setError; - для сторонніх компонентів (селекти, редактори) -
Controllerз React Hook Form.
Коли API на Laravel не пропускає дані, він відповідає 422 Unprocessable Content з JSON:
{
"message": "The email has already been taken. (and 1 more error)",
"errors": {
"email": ["Ця адреса вже зайнята."],
"items.0.qty": ["Кількість має бути не менше 1."]
}
}
Laravel повертає JSON (а не редирект назад), якщо запит має заголовок Accept: application/json - його обов'язково треба надсилати з fetch.
З React Hook Form - setError:
const { register, handleSubmit, setError, formState: { errors } } = useForm();
async function onSubmit(values) {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(values),
});
if (response.status === 422) {
const { errors: serverErrors } = await response.json();
for (const [field, messages] of Object.entries(serverErrors)) {
setError(field, { type: 'server', message: messages[0] }, { shouldFocus: true });
}
return;
}
if (!response.ok) {
setError('root.server', { message: 'Не вдалося зберегти. Спробуйте ще раз.' });
return;
}
}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
Що тут важливо:
- ключі Laravel з крапками (
items.0.qty) збігаються з іменами полів React Hook Form для масивів (register('items.0.qty')) - помилка потрапить точно до потрібного поля; - кілька повідомлень на поле - Laravel повертає масив; зазвичай показують перше;
- помилки, що не стосуються поля (сервер недоступний, 403, 500) - окремо, через
root.*; - серверні помилки скидаються при наступній зміні поля чи відправці - так поводиться React Hook Form для
setError.
Без бібліотеки - те саме зі станом: const [errors, setErrors] = useState({}) і useActionState, що повертає errors з 422-відповіді.
Inertia поводиться інакше: там Laravel відповідає не 422, а редиректом назад з помилками в сесії, і вони приходять у сторінку як проп errors (useForm().errors) - ручна обробка не потрібна.
Інші статуси, які варто обробити: 419 (сесія чи CSRF-токен застаріли - запропонувати оновити сторінку) і 429 (забагато спроб).
Оптимістичне оновлення - показати результат дії одразу, не чекаючи відповіді сервера: повідомлення з'являється в чаті миттєво, лайк зараховується одразу. Якщо сервер відмовив - повернути як було.
useOptimistic(value, reducer?) (React 19) повертає тимчасовий стан, що діє, поки виконується дія:
import { useOptimistic, useState } from 'react';
function Comments({ initialComments }) {
const [comments, setComments] = useState(initialComments);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(current, text) => [...current, { id: `temp-${Date.now()}`, text, sending: true }],
);
async function addComment(formData) {
const text = formData.get('text');
addOptimistic(text); // одразу на екрані
try {
const saved = await api.createComment(text);
setComments((list) => [...list, saved]); // справжні дані
} catch {
toast.error('Коментар не надіслано'); // оптимістичний стан зникне сам
}
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.6 : 1 }}>{c.text}</li>
))}
</ul>
<form action={addComment}>
<input name="text" />
</form>
</>
);
}
Як це працює:
- поки дія виконується,
optimisticComments= результатreducer(чи значення, передане в сеттер); - коли дія завершилася, React повертає
optimisticCommentsдоvalue- тобто доcomments; - успіх:
commentsуже містить збережений коментар - користувач бачить справжні дані; - помилка:
commentsне змінено - тимчасовий коментар просто зникає. Ручного «відкату» не треба.
Умови:
- сеттер оптимістичного стану треба викликати всередині дії (
<form action>,startTransition). Поза нею React попередить, і оптимістичне значення лише мигне; reducerмає бути чистим.
Що враховувати в UX:
- позначати непідтверджені дані (напівпрозорість, «надсилається»), щоб користувач розумів, що вони ще не збережені;
- повідомляти про невдачу - тихе зникнення коментаря гірше за помилку;
- не застосовувати для незворотних дій (оплата, видалення без кошика): оптимістичний «успіх» там вводить в оману;
- тимчасові ключі - згенерований id, щоб React не переплутав оптимістичний елемент зі справжнім.
Подвійний клік, Enter у полі під час повільного запиту, повторна відправка після «зависання» - типові джерела дублікатів замовлень і коментарів. Захист потрібен на кількох рівнях.
1. Інтерфейс - не дати відправити вдруге:
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Оформлюємо...' : 'Оформити'}</button>;
}
З useActionState дії ставляться в чергу й виконуються по черзі, а не паралельно, - результат передбачуваний навіть при повторних натисканнях. Але вони все одно виконаються всі.
2. Своя логіка відправки - прапорець у ref, а не лише стан:
const submitting = useRef(false);
async function handleSubmit(values) {
if (submitting.current) return;
submitting.current = true;
try {
await api.placeOrder(values);
} finally {
submitting.current = false;
}
}
Стан (useState) оновлюється з рендером - два швидкі кліки можуть обидва побачити false. Ref змінюється одразу.
3. Гонитва відповідей - коли новий запит починається до завершення попереднього (пошук, фільтри, автозбереження). Стара відповідь може прийти пізніше й перезаписати нову:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then((r) => r.json())
.then(setResults)
.catch((e) => { if (e.name !== 'AbortError') throw e; });
return () => controller.abort(); // скасувати попередній запит
}, [query]);
Або бібліотеки даних (TanStack Query), що розв'язують це самі.
4. Сервер - останній і головний рубіж. Клієнтський захист обходиться повторним запитом, повільною мережею з повтором, двома вкладками:
- ключ ідемпотентності: клієнт генерує
crypto.randomUUID()при відкритті форми й надсилає його з запитом; сервер не створює вдруге замовлення з тим самим ключем; - унікальні обмеження в базі (одна підписка на email);
- блокування (
Cache::lockу Laravel) для операцій, що не повинні виконуватися паралельно.
5. Після успіху - перенаправлення чи скидання форми, щоб «Назад» і повторне натискання не відправили ті самі дані знову.
Файлове поле завжди неконтрольоване: задати йому значення з коду не можна, тож value не передають. Файли читають з події чи з FormData.
function AvatarForm() {
const [preview, setPreview] = useState(null);
useEffect(() => () => preview && URL.revokeObjectURL(preview), [preview]);
function onChange(e) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
alert('Файл більший за 2 МБ');
e.target.value = ''; // скинути вибір
return;
}
setPreview(URL.createObjectURL(file));
}
async function upload(formData) {
await fetch('/api/avatar', { method: 'POST', body: formData, headers: { Accept: 'application/json' } });
}
return (
<form action={upload}>
<input type="file" name="avatar" accept="image/png,image/jpeg" onChange={onChange} />
{preview && <img src={preview} alt="Попередній перегляд" width={120} />}
<button>Завантажити</button>
</form>
);
}
Що тут важливо:
URL.createObjectURLстворює посилання на файл у пам'яті браузера. Його треба звільняти (revokeObjectURL) при заміні файлу й знищенні компонента, інакше пам'ять не звільняється до закриття вкладки;Content-Typeне задавати вручну дляFormData- браузер сам поставитьmultipart/form-dataз межею (boundary);acceptі перевірка розміру - лише зручність; сервер перевіряє тип за вмістом (image,mimes) і розмір заново;multiple-e.target.filesдає список; уFormData-formData.getAll('photos').
Прогрес завантаження. fetch не повідомляє про прогрес відправки тіла. Для смуги прогресу - XMLHttpRequest (xhr.upload.onprogress) чи бібліотека, що його використовує.
Великі файли (відео, архіви):
- завантаження частинами з продовженням після обриву;
- пряме завантаження в S3 за підписаним URL, отриманим від Laravel (
Storage::temporaryUploadUrl()): файл іде з браузера в сховище, минаючи сервер застосунку; - обмеження сервера (
upload_max_filesize,client_max_body_sizeу Nginx) мають відповідати очікуваним розмірам.
PUT/PATCH з файлами в Laravel: PHP історично розбирає multipart лише для POST - тому відправляють POST з полем _method=PUT.
Доступність і UX: кнопка вибору файлу має мітку, а для перетягування (drag and drop) лишається звичайне поле як запасний варіант.
Докладніше в документації: input: читання значень при відправці
У React 19 є три підходи до форм, і вони добре поєднуються.
1. Нативна форма + дії (action, useActionState, useFormStatus)
const [state, formAction, isPending] = useActionState(saveSettings, initial);
<form action={formAction}>...</form>
- мінімум коду: значення читаються з
FormData, без стану на кожне поле; - вбудовані стан очікування, черга дій, оптимістичні оновлення;
- прогресивне покращення з Server Functions (Next.js та інші RSC-фреймворки): форма відправляється навіть до завантаження JavaScript, а з
permalinkуuseActionStateбраузер знає, куди перейти; - слабкі місця: валідація на клієнті під час введення, залежні поля, динамічні списки - доводиться дописувати вручну.
Добре для: форм налаштувань, підписки, пошуку, коментарів - коротких форм, де головне - відправка й показ результату.
2. Контрольований стан (useState, useReducer)
- повний контроль над кожним символом: форматування, обчислювані поля, умовна логіка;
- кожне введення - рендер компонента з формою. На великих формах помітно без мемоізації й розбиття на компоненти.
Добре для: невеликих інтерактивних форм з тісною взаємодією полів (калькулятори, конфігуратори).
3. Бібліотека (React Hook Form + Zod, TanStack Form)
- валідація за схемою зі спільним типом, режими перевірки (
onBlur,onChange), фокус на помилці; - масиви полів (
useFieldArray), майстри з кроками, «брудні» поля; - продуктивність великих форм - мінімум рендерів.
Добре для: великих бізнес-форм, адмінок, форм з динамічними частинами.
Як поєднувати:
- бібліотека для стану й валідації, а відправка - у дію (
startTransitionусерединіonSubmit) - щоб отриматиisPendingі оптимістичні оновлення; - нативна форма з
useActionState, а для одного складного поля - контрольований компонент.
Що не залежить від підходу:
- сервер перевіряє все - клієнтська валідація лише прискорює зворотний зв'язок;
- серверні помилки мають потрапити до полів (422 від Laravel чи
errorsвід Inertia); - доступність (мітки,
aria-invalid, фокус) і захист від подвійної відправки; - узгодженість у проєкті: один основний підхід, а не три різні в сусідніх формах.
З Inertia вибір часто вже зроблено: useForm чи компонент <Form> з Inertia дають стан, помилки й відправку з урахуванням маршрутизації Laravel.
Докладніше в документації: form: відправка через Server Function