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

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 не переплутав оптимістичний елемент зі справжнім.

Докладніше в документації: useOptimistic

Подвійний клік, 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. Після успіху - перенаправлення чи скидання форми, щоб «Назад» і повторне натискання не відправили ті самі дані знову.

Докладніше в документації: form: стан очікування

Файлове поле завжди неконтрольоване: задати йому значення з коду не можна, тож 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