Senior: питання на співбесіді з теми «Форми й Actions»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Оптимістичне оновлення - показати результат дії одразу, не чекаючи відповіді сервера: повідомлення з'являється в чаті миттєво, лайк зараховується одразу. Якщо сервер відмовив - повернути як було.
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