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

Питання на співбесіді з React

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

100 питань

<Suspense> показує fallback, поки щось усередині «не готове»:

  • компонент, що читає незавершений проміс через use();
  • лінивий компонент (lazy(() => import(...))), чий код ще завантажується;
  • дані фреймворку з підтримкою Suspense (серверні компоненти, завантажувачі Next.js, TanStack Query в режимі useSuspenseQuery).
<Suspense fallback={<PageSkeleton />}>
  <ProfileHeader />
  <Suspense fallback={<PostsSkeleton />}>
    <Posts />
  </Suspense>
</Suspense>

Як поводяться межі:

  • «призупинений» компонент показує fallback найближчої межі вгору по дереву;
  • уся межа відображає fallback, поки не готовий хоча б один компонент усередині, - не лише той, що чекає;
  • вкладені межі дають поступове відкриття: спершу сторінка зі скелетом постів, потім пости.

Проєктування станів завантаження - питання UX, а не техніки:

  • межі за змістовими блоками, а не на кожному компоненті. Двадцять незалежних спінерів, що з'являються в довільному порядку, гірші за один скелет сторінки;
  • те, що має з'являтися разом, - в одній межі (заголовок і обкладинка), те, що може відстати, - в окремій (коментарі, рекомендації);
  • fallback - скелет того самого розміру, щоб сторінка не стрибала при появі вмісту.

Повторне завантаження без «блимання»: якщо вже показаний вміст знову призупиняється (новий запит при переході між вкладками), межа заховала б готовий вміст за fallback. Щоб цього уникнути, оновлення позначають як transition (startTransition, useTransition) - React залишає старий вміст на екрані, поки готується новий. Також useDeferredValue для значень з введення.

Помилки: відхилений проміс у межі Suspense іде до найближчого error boundary. Зазвичай їх ставлять поруч: межа помилок навколо межі Suspense.

Серверний рендер і стримінг: з renderToPipeableStream/RSC сервер одразу відправляє HTML з fallback-ами, а готові частини дописує в потік. Межі Suspense визначають, які частини сторінки можуть приходити пізніше, - і селективну гідратацію (React гідратує спочатку ту частину, з якою користувач взаємодіє).

Чого Suspense не робить сам по собі: не завантажує дані. Він лише реагує на «призупинення» - джерело даних має його підтримувати (use зі стабільним промісом, фреймворк, бібліотека). useEffect + useState з Suspense не працюють.

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

<Activity> приховує й відновлює частину інтерфейсу разом з її станом. Це проміжний варіант між «показати» і «прибрати з дерева».

import { Activity } from 'react';

<Activity mode={tab === 'comments' ? 'visible' : 'hidden'}>
  <Comments />
</Activity>

Порівняння трьох підходів:

Підхід Стан Ефекти DOM
{show && <Comments />} губиться при приховуванні очищаються видаляється
CSS hidden зберігається продовжують працювати лишається
<Activity mode="hidden"> зберігається очищаються (підписки знімаються) лишається, display: none

Що відбувається в прихованому режимі:

  • діти приховуються через display: none;
  • ефекти знищуються - таймери, підписки, з'єднання закриваються, ніби компонент розмонтовано;
  • стан і DOM зберігаються - введений текст, позиція прокрутки, розгорнуті секції;
  • компоненти продовжують перерендерюватися при зміні props, але з нижчим пріоритетом, ніж видимий вміст;
  • при поверненні у visible ефекти створюються знову, стан на місці.

Застосування:

  • вкладки й бічні панелі, де користувач очікує, що повернеться до того самого стану (заповнена форма, позиція в списку);
  • попередній рендер: прихована вкладка, яку користувач, ймовірно, відкриє наступною, може заздалегідь завантажити дані й підготувати розмітку - перемикання буде миттєвим;
  • історія навігації: зберегти попередній екран, щоб «Назад» відкривав його миттєво.

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

  • ефекти мають правильно прибирати за собою - Activity активно використовує очищення ефектів. Компонент, що не відписується в очищенні, при приховуванні «протікатиме» (те, що й так ловить StrictMode);
  • пам'ять: прихований вміст лишається в DOM і пам'яті. Десятки прихованих важких екранів - помітне навантаження;
  • медіа: відео й аудіо в прихованому вмісті продовжують грати, якщо не зупинити їх в очищенні ефекту;
  • прихована Activity, що рендерить лише текст, не рендерить нічого - немає DOM-елемента, якому призначити display: none.

Раніше подібне робили вручну: тримали стан угорі або ховали через CSS, змиряючись із працюючими ефектами.

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

Найпідступніші помилки стану - неможливі комбінації, які структура даних дозволяє, а логіка - ні.

1. Суперечливі прапорці → одне поле статусу:

// погано: що означає isLoading && isError? а isSuccess && isError?
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);

// добре: рівно один стан у кожен момент
const [status, setStatus] = useState('idle');   // 'idle' | 'loading' | 'error' | 'success'

Ще краще - дискримінований тип, де дані існують лише в «правильних» станах:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: Order[] };

TypeScript не дасть прочитати data в стані error.

2. Дублювання → ідентифікатори:

// погано: копія об'єкта - зміна в списку не потрапить у selectedItem
const [selectedItem, setSelectedItem] = useState(items[0]);

// добре
const [selectedId, setSelectedId] = useState(items[0].id);
const selectedItem = items.find((item) => item.id === selectedId);

3. Глибока вкладеність → нормалізація. Дерево (категорії з підкатегоріями, коментарі з відповідями) зручно відображати, але незручно оновлювати: зміна глибокого вузла вимагає копіювати весь шлях. Нормалізована форма - як таблиці в базі:

{
  commentsById: {
    1: { id: 1, text: '...', childIds: [2, 3] },
    2: { id: 2, text: '...', childIds: [] },
  },
  rootIds: [1],
}

Оновлення одного коментаря - одна заміна за id. Redux Toolkit має для цього createEntityAdapter.

4. Групування пов'язаного стану: значення, що завжди змінюються разом (координати x і y, поля однієї форми), - в одному об'єкті чи редукторі, а не в окремих useState, які легко оновити неузгоджено.

5. Надлишковий стан → обчислення під час рендеру.

Перевірка структури: для кожної комбінації значень у стані запитати - чи вона можлива в реальності? Якщо ні, структуру варто змінити так, щоб її неможливо було виразити. «Зробити неможливі стани невиразними» - головна ідея.

Для складних сценаріїв (багатокрокові форми, процеси оплати, завантаження з повторами) - явний скінченний автомат: редуктор з переходами або бібліотека XState.

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

Оптимістичне оновлення - показати результат дії одразу, не чекаючи відповіді сервера, і відкотити, якщо сервер повернув помилку. Інтерфейс відчувається миттєвим: «лайк», позначка завдання, перейменування.

Варіант через кеш запиту:

const queryClient = useQueryClient();

const toggleTodo = useMutation({
  mutationFn: (todo) =>
    fetch(`/api/todos/${todo.id}`, { method: 'PATCH', body: JSON.stringify({ done: !todo.done }) }),

  onMutate: async (todo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });          // 1
    const previous = queryClient.getQueryData(['todos']);               // 2
    queryClient.setQueryData(['todos'], (old) =>                        // 3
      old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
    );
    return { previous };
  },

  onError: (error, todo, context) => {
    queryClient.setQueryData(['todos'], context.previous);              // 4
    toast.error('Не вдалося зберегти');
  },

  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), // 5
});

Кроки й навіщо кожен:

  1. скасувати поточні запити списку - інакше відповідь, що вже летить, перезапише оптимістичні дані старими;
  2. зберегти знімок для відкату;
  3. оновити кеш - інтерфейс змінюється миттєво;
  4. при помилці - відкотити до знімка і повідомити користувача;
  5. після завершення - перезапитати дані з сервера, щоб кеш точно відповідав реальності.

Простіший варіант - через змінні мутації: не чіпати кеш, а в компоненті показувати mutation.variables, поки мутація в стані isPending. Менше коду, відкат автоматичний (просто зникає тимчасове відображення), але зміна видна лише там, де рендериться мутація.

React 19 useOptimistic - схожа ідея для дій і форм без бібліотеки: тимчасовий стан, що діє до завершення асинхронної дії.

Коли оптимізм доречний: дія майже завжди успішна, легко відкочується і не має незворотних наслідків.

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

Пастки:

  • кілька швидких мутацій однієї сутності: відкат однієї може затерти іншу - враховувати це при розробці onError (чи серіалізувати мутації);
  • згенеровані сервером поля (id, дати) в оптимістичному записі - тимчасові значення, які замінить відповідь;
  • повідомлення про відкат обов'язкове - тихе «повернення» стану користувач сприйме як баг.

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

Документація React пропонує думати про інтерфейс декларативно: не «що змінити після кліку», а «які візуальні стани бувають і що переводить з одного в інший».

Процес:

  1. перелічити візуальні стани: форма відповіді на питання - empty (поле порожнє), typing, submitting, error, success;
  2. визначити, що їх змінює: введення тексту, натискання «Надіслати», відповідь сервера (успіх чи помилка);
  3. описати стан мінімальним набором змінних без суперечностей;
  4. підключити обробники, що змінюють стан.

Редуктор як скінченний автомат:

function quizReducer(state, event) {
  switch (state.status) {
    case 'typing':
      if (event.type === 'submit') return { ...state, status: 'submitting' };
      if (event.type === 'change') return { ...state, answer: event.value };
      return state;
    case 'submitting':
      if (event.type === 'resolved') return { ...state, status: 'success' };
      if (event.type === 'rejected') return { ...state, status: 'typing', error: event.error };
      return state;   // під час відправки зміна тексту ігнорується
    case 'success':
      return state;   // кінцевий стан
    default:
      return state;
  }
}

Головна відмінність від звичайного редуктора - перевірка поточного стану перед обробкою події. Подія, недопустима в цьому стані, просто ігнорується: подвійне натискання «Надіслати» під час відправки не створить другий запит.

Що це дає:

  • неможливі переходи неможливі: не можна «надіслати» з success, змінити текст під час submitting;
  • інтерфейс - функція від стану: disabled={state.status === 'submitting'}, повідомлення про успіх лише в success;
  • легко тестувати: редуктор - чиста функція, перелік станів і переходів можна перевірити повністю;
  • легко побачити всі стани: для кожного з них можна відрендерити компонент окремо (наприклад, у Storybook) і перевірити дизайн.

Коли брати бібліотеку (XState): паралельні й вкладені стани, таймери й затримки як частина логіки, складні багатокрокові процеси (онбординг, оформлення замовлення з кількома способами оплати), потреба візуалізувати автомат для команди.

Для простих компонентів досить одного поля status замість кількох булевих прапорців - це вже більша частина користі.

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

Кеш серверних даних корисний, доки він актуальний. Є три джерела змін, і кожне потребує свого механізму.

1. Зміни, зроблені самим користувачем (мутації). Після успішного запиту кеш, що залежить від змінених даних, треба оновити:

useMutation({
  mutationFn: createOrder,
  onSuccess: (order) => {
    queryClient.invalidateQueries({ queryKey: ['orders'] });          // позначити застарілим і перезапитати
    queryClient.setQueryData(['orders', order.id], order);            // покласти відповідь у кеш одразу
  },
});

invalidateQueries з префіксом ключа (['orders']) зачіпає всі запити, ключ яких з нього починається: списки з різними фільтрами, сторінки пагінації. Тому ієрархічні ключі (['orders', { status }], ['orders', id]) важливі з самого початку.

2. Зміни, зроблені іншими (інші користувачі, фонові задачі).

  • повторний запит при поверненні на вкладку (refetchOnWindowFocus, увімкнено за замовчуванням) і з інтервалом (refetchInterval) - простий варіант без інфраструктури;
  • події в реальному часі - WebSocket чи SSE (у Laravel - Reverb + Echo). Подія від сервера інвалідує чи оновлює кеш:
useEffect(() => {
  const channel = echo.private(`team.${teamId}`);
  channel.listen('OrderShipped', (event) => {
    queryClient.setQueryData(['orders', event.order.id], event.order);
    queryClient.invalidateQueries({ queryKey: ['orders'], exact: false, refetchType: 'active' });
  });
  return () => echo.leave(`team.${teamId}`);
}, [teamId, queryClient]);

setQueryData чи invalidateQueries:

  • setQueryData - коли подія містить повні актуальні дані: без додаткового запиту;
  • invalidateQueries - коли подія лише сигналізує «щось змінилося»: менше даних у повідомленні, але запит на сервер. refetchType: 'active' перезапитує лише те, що зараз на екрані, решта оновиться при наступному використанні.

3. Застарівання за часом - staleTime. Для кожного типу даних - свій: довідники (міста, категорії) - години, список замовлень - секунди, курс валют - хвилина.

Пастки:

  • шторм запитів: подія, що інвалідує широкий ключ, у тисячі відкритих вкладок одночасно - тисячі запитів на сервер. Варто передавати дані в самій події або додавати випадкову затримку;
  • порядок подій: подія може прийти раніше, ніж відповідь на мутацію в цій самій вкладці, - перевіряти версію (updated_at) перед записом у кеш;
  • авторизація каналів: приватні канали WebSocket мають перевіряти, чи користувач має доступ до даних, - інакше кеш отримає чужі дані.

Докладніше в документації: TanStack Query: інвалідація запитів

Оптимістичне оновлення - показати результат дії одразу, не чекаючи відповіді сервера: повідомлення з'являється в чаті миттєво, лайк зараховується одразу. Якщо сервер відмовив - повернути як було.

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

Серверна функція з 'use server' виглядає як звичайна функція, яку викликає компонент. Насправді Next.js створює для неї ендпойнт, доступний прямим POST-запитом - не лише з вашого інтерфейсу.

'use server';

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } });   // будь-хто видалить будь-який пост
}

Що Next.js робить для захисту сам:

  • зашифровані недетерміновані ідентифікатори дій, що змінюються між збірками;
  • видалення невикористаних дій з клієнтського бандла;
  • шифрування змінних із замикань (значень, захоплених функцією, оголошеною всередині компонента) - вони ходять клієнтом і назад, але в зашифрованому вигляді. Ключ генерується на кожну збірку (для кількох серверів - спільний NEXT_SERVER_ACTIONS_ENCRYPTION_KEY);
  • перевірка джерела: заголовок Origin порівнюється з Host - захист від CSRF; для проксі-конфігурацій - serverActions.allowedOrigins.

Але документація прямо застерігає: це зменшує ризики, а автентифікацію й авторизацію треба перевіряти всередині кожної дії.

Правильно:

'use server';

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post || post.authorId !== session.user.id) throw new Error('Forbidden');   // захист від IDOR

  await db.post.delete({ where: { id: postId } });
  revalidatePath('/posts');
}

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

  • перевірка лише на сторінці: сторінка доступна лише адміну, тож «дії на ній безпечні». Ні - перевірка доступу до сторінки не поширюється на дії, оголошені в ній;
  • довіра аргументам: postId, ціна, роль приходять від клієнта й можуть бути будь-якими. Валідація (Zod) обов'язкова, як для будь-якого API;
  • повернення зайвого: повернене значення потрапляє в браузер - лише потрібні поля;
  • покладатися на шифрування замикань для секретів - документація не радить; секрети не повинні опинитися в замиканні взагалі.

Рекомендований підхід - шар доступу до даних: модуль з import 'server-only', де зібрано перевірки прав і запити до бази. Серверні дії лишаються тонкими й викликають його. Так перевірки не залежать від того, звідки викликано код.

Аналогія з Laravel: серверна дія - це маршрут контролера. Ніхто не робить контролер без authorize() лише тому, що на нього немає посилання в інтерфейсі.

Докладніше в документації: Next.js: безпека даних

Гідратація - процес, коли React у браузері «підхоплює» HTML, згенерований на сервері: прив'язує обробники подій і стан до вже наявних елементів. React очікує, що перший рендер у браузері дасть точно такий самий результат, що й на сервері. Розбіжність - помилка гідратації.

Наслідки: React виводить помилку й перерендерює частину дерева на клієнті (повільніше, можливе «мигання»), а атрибути можуть лишитися невиправленими. Документація радить вважати розбіжності багами.

Найчастіші причини:

1. Значення, різні на сервері й клієнті:

<p>Згенеровано: {new Date().toLocaleTimeString()}</p>   // інший час
<p>{Math.random()}</p>
<p>{price.toLocaleString()}</p>                           // інша локаль чи часовий пояс

2. Перевірки середовища в рендері:

{typeof window !== 'undefined' && <MobileMenu />}   // на сервері немає, на клієнті є

3. Дані з браузера: localStorage, розмір вікна, тема з налаштувань системи - на сервері їх немає.

4. Некоректна вкладеність HTML: <p> всередині <p>, <div> у <p>, <a> у <a> - браузер «виправляє» розмітку при розборі, і дерево відрізняється від очікуваного.

5. Розширення браузера, що змінюють DOM до гідратації (перекладачі, менеджери паролів).

Як виправляти:

  • значення лише для клієнта - після монтування:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);

return <p>Згенеровано: {mounted ? new Date().toLocaleTimeString() : '...'}</p>;

Перший рендер збігається з сервером, а одразу після гідратації - другий з клієнтськими даними. У Next.js - також dynamic(() => import(...), { ssr: false }) для компонентів, яким сервер узагалі не потрібен;

  • дати й числа - форматувати з явною локаллю й часовим поясом однаково на обох боках (Intl.DateTimeFormat('uk', { timeZone: 'Europe/Kyiv' })) або передавати вже відформатований рядок із сервера;
  • ідентифікатори - useId, а не лічильник чи Math.random();
  • неминуча розбіжність одного елемента (позначка часу) - suppressHydrationWarning на ньому. Діє лише на один рівень і не виправляє вміст - це запасний вихід, а не рішення;
  • тема без мигання - встановлювати клас на <html> вбудованим скриптом до гідратації і suppressHydrationWarning на <html>.

Діагностика: у режимі розробки React показує, у якому елементі й чим відрізнялися дерева; onRecoverableError у hydrateRoot дає змогу збирати ці помилки в моніторинг на продакшені.

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

proxy.ts (до Next.js 16 - middleware.ts) - код, що виконується до обробки запиту маршрутом: може переписати URL, перенаправити, змінити заголовки чи cookies або відповісти одразу.

// proxy.ts у корені проєкту
import { NextResponse, type NextRequest } from 'next/server';

export function proxy(request: NextRequest) {
  if (!request.cookies.has('session')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'],
};

Чому перейменували. Назва «middleware» асоціювалася з middleware Express чи Laravel - ланцюжком обробників навколо запиту, на який кладуть усе підряд. Next.js прямо радить не покладатися на цей механізм, якщо є інші варіанти, і перейменував його на «proxy», щоб підкреслити роль - мережевий рубіж перед застосунком (може навіть виконуватися на CDN, окремо від рендеру). Перехід - кодмод middleware-to-proxy; у Next.js 16 proxy за замовчуванням працює на Node.js runtime.

Для чого proxy доречний:

  • швидкі перенаправлення й переписування (локалізація за мовою, A/B-тести, старі URL);
  • оптимістичні перевірки: чи є взагалі cookie сесії - перенаправити на вхід, не рендерячи сторінку;
  • заголовки (безпека, CORS для обробників маршрутів), логування.

Чому не будувати на ньому авторизацію:

  • proxy не замінює перевірок у даних. Сторінка, серверна дія чи обробник маршруту можуть бути викликані способами, які matcher не охоплює (зміна шляху, нові маршрути, прямий виклик серверної дії). Перевірка має бути там, де читаються й змінюються дані;
  • у 2025 році була вразливість (CVE-2025-29927), що дозволяла обійти middleware спеціальним заголовком у деяких самостійно розгорнутих версіях. Застосунки, де авторизація була лише в middleware, виявилися відкритими. Багаторівневий захист таку помилку пережив би;
  • обмежений доступ до контексту: proxy виконується окремо від рендеру, не повинен покладатися на спільні модулі чи глобальний стан; повна перевірка прав з запитами до бази тут дорога.

Рекомендований підхід:

  1. proxy - оптимістичне перенаправлення неавтентифікованих користувачів (UX і зменшення навантаження);
  2. шар доступу до даних з перевіркою сесії й прав - у кожному серверному компоненті, дії, обробнику маршруту.

Аналог у Laravel - різниця між глобальним middleware і authorize() у контролері чи політиках: перший фільтрує, другий справді захищає ресурс.

Докладніше в документації: Next.js: proxy.js

За замовчуванням кожен перехід в Inertia - це запит до контролера Laravel, який обчислює всі props сторінки. Для сторінки зі списком і фільтрами зміна одного фільтра означала б заново обчислити й довідники, і статистику, і все інше.

Часткове перезавантаження - запит лише потрібних props для поточної сторінки:

import { router } from '@inertiajs/react';

function CompanyFilter({ companies }) {
  return (
    <select
      onChange={(e) =>
        router.reload({
          data: { company: e.target.value },
          only: ['users'],          // повернути лише список користувачів
        })
      }
    >
      {companies.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
    </select>
  );
}
  • only: [...] - які props повернути;
  • except: [...] - які пропустити;
  • router.reload() - скорочення для візиту на поточну URL; часткові перезавантаження працюють лише для тієї самої сторінки (того самого компонента).

Чому замикання на сервері обов'язкові. Сервер повертає лише запитані props, але щоб їх не обчислювати, значення мають бути ледачими:

return Inertia::render('Users/Index', [
    'users' => fn () => User::query()->filter(request()->only('company'))->paginate(20),
    'companies' => fn () => Company::orderBy('name')->get(['id', 'name']),
    'stats' => fn () => $statsService->heavyCalculation(),
]);

Без fn () => запит до companies і важкий stats виконуються на кожному запиті, навіть якщо їх потім викинуть з відповіді.

Інші типи props Inertia (v3):

  • Inertia::optional(fn () => ...) - не надсилається при звичайних переходах, лише коли явно запитаний через only (раніше Inertia::lazy, видалено у v3);
  • Inertia::defer(fn () => ...) - сторінка рендериться без цього prop, а він довантажується окремим запитом одразу після показу (повільна статистика);
  • Inertia::merge(...) - нові дані дописуються до наявних, а не замінюють їх (нескінченний скрол, «Завантажити ще»);
  • Inertia::once(...) - обчислюється один раз і запам'ятовується клієнтом між переходами.

Пастки:

  • назва prop в only має точно збігатися з ключем у контролері - помилка в назві мовчки дає «нічого не оновилося»;
  • спільні дані (з HandleInertiaRequests) теж підпадають під only/except - для них правило замикань те саме;
  • стан сторінки: часткове перезавантаження за замовчуванням зберігає стан компонента (preserveState) - фільтри й введені значення не скидаються.

Докладніше в документації: Inertia: часткові перезавантаження

Компоненти на кшталт списку, таблиці чи випадного списку працюють з даними будь-якого типу. Без узагальнень доводиться писати any - і зв'язок між даними та колбеками губиться.

Узагальнений компонент:

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => string | number;
  renderItem: (item: T) => ReactNode;
  onSelect?: (item: T) => void;
};

function List<T>({ items, getKey, renderItem, onSelect }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)} onClick={() => onSelect?.(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}
<List
  items={users}                      // T = User - виведено з items
  getKey={(user) => user.id}
  renderItem={(user) => user.name}   // user: User
  onSelect={(user) => open(user.id)}
/>

TypeScript виводить T з переданих даних, і всі колбеки отримують правильний тип. Звернення до неіснуючого поля - помилка компіляції.

Обмеження типу параметра:

function Table<T extends { id: number }>({ rows, columns }: TableProps<T>) { /* ... */ }

Тепер можна використовувати row.id всередині й не передавати getKey.

Колонки, прив'язані до полів даних:

type Column<T> = {
  key: keyof T;
  header: string;
  render?: (value: T[keyof T], row: T) => ReactNode;
};

key: keyof T дозволяє лише існуючі поля - перейменування поля в моделі одразу підсвітить усі таблиці, де воно використовується.

Синтаксис у .tsx: стрілкова функція const List = <T,>(props: ListProps<T>) => ... потребує коми після T - інакше <T> прочитається як JSX-тег. Оголошення function List<T>(...) цієї проблеми не має.

Явний параметр можна передати в JSX, якщо виведення неможливе: <List<User> items={[]} ... />.

Пастки:

  • memo чи forwardRef губить узагальнення: memo(List) повертає компонент з T = unknown. Рішення - приведення типу (memo(List) as typeof List) або відмова від обгортки (з React Compiler потреба в ручному memo зменшується);
  • надто складні узагальнення (умовні типи, кілька параметрів) погіршують повідомлення про помилки - для команди простіший тип часто корисніший за максимально точний.

Докладніше в документації: Узагальнені типи (Generics)

Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 33 Middle 34 Senior 33

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії