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

React: питання на співбесіді рівня Senior

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

33 питань

Власний хук - функція з назвою на use, що викликає інші хуки. Спосіб винести логіку зі станом і ефектами з компонента й перевикористати.

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebouncedValue(query);
  const results = useSearch(debouncedQuery);
  // ...
}

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

Ознаки, що варто виділити хук:

  • той самий набір useState + useEffect повторюється в кількох компонентах;
  • ефект синхронізується з чимось зовнішнім (useOnlineStatus, useMediaQuery, useWebSocket) - хук ховає деталі за зрозумілою назвою;
  • компонент став довгим, і логіка заважає читати розмітку.

Чого уникати:

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

Тестують хуки через renderHook з React Testing Library.

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

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

const ThemeContext = createContext('light');

<ThemeContext value={theme}>   {/* React 19; раніше ThemeContext.Provider */}
  <App />
</ThemeContext>

const theme = useContext(ThemeContext);

Чому всі споживачі перерендерюються: коли значення провайдера змінилося (порівняння через Object.is), React перерендерює кожен компонент, що читає цей контекст, навіть якщо використовує лише частину значення. Вибіркової підписки на поле в контексту немає.

Типова пастка - новий об'єкт на кожен рендер:

<AuthContext value={{ user, login, logout }}>

Кожен рендер провайдера створює новий об'єкт - і всі споживачі перерендерюються, навіть якщо user не змінився.

Як з цим жити:

  • Мемоізувати значення: const value = useMemo(() => ({ user, login, logout }), [user]), функції - через useCallback.
  • Розділяти контексти: дані, що часто змінюються, окремо від рідкісних; стан окремо від функцій-диспетчерів (StateContext і DispatchContext).
  • Не тримати в контексті часто змінюваний стан великої частини застосунку (значення полів форми, позиція курсору). Для такого краще стор з селекторами (Zustand, Redux, Jotai), де компонент підписується лише на потрібну частину.

Context - не менеджер стану, а механізм передачі. Він чудово працює для рідко змінюваних значень і погано масштабується для глобального стану, що часто оновлюється.

Докладніше в документації: Передача даних глибоко через Context

Більшість стану живе в React (useState, useReducer). Але інколи дані зберігаються зовні: у сторонній бібліотеці стану, в API браузера (navigator.onLine, matchMedia, localStorage), у власному класі-сховищі.

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

useSyncExternalStore - офіційний спосіб читати зовнішнє джерело узгоджено:

import { useSyncExternalStore } from 'react';

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,   // знімок на клієнті
    () => true,               // знімок для серверного рендеру
  );
}

Три аргументи:

  1. subscribe(callback) - підписатися на зміни й повернути функцію відписки;
  2. getSnapshot() - поточне значення;
  3. getServerSnapshot() - значення для серверного рендеру й гідратації (на сервері немає navigator).

Ключові правила:

  • getSnapshot має повертати те саме значення, доки дані не змінилися. Якщо він щоразу створює новий об'єкт (() => ({ ...store.state })), React вважатиме, що дані змінилися на кожному рендері, - нескінченний цикл і помилка. Повертайте збережене незмінне значення або примітив;
  • subscribe - стабільна функція, оголошена поза компонентом (чи в useCallback), інакше React перепідписуватиметься на кожному рендері;
  • оновлення з такого джерела не можна позначити як transition - вони завжди синхронні.

Хто вже використовує: Redux (useSelector), Zustand, TanStack Query та інші бібліотеки стану побудовані на ньому. У застосунку цей хук зазвичай потрібен для API браузера чи власного сховища.

Приклад із localStorage: підписка на подію storage (зміни з інших вкладок) плюс власна подія для змін у поточній вкладці - і стан синхронізований між вкладками.

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

use(resource) читає значення з проміса або контексту. На відміну від хуків, його можна викликати в умовах і циклах.

1. Читання проміса з Suspense:

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);   // «призупиняє» компонент до виконання проміса
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

function Post({ commentsPromise }) {
  return (
    <Suspense fallback={<p>Завантаження коментарів...</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

Поки проміс виконується, показується fallback найближчого <Suspense>. Відхилений проміс потрапляє в найближчу межу помилок (error boundary).

Головна пастка - звідки береться проміс. Проміс, створений під час рендеру клієнтського компонента (use(fetch('/api/comments'))), буде новим на кожному рендері - React показуватиме fallback знову й знову. Проміс має бути стабільним:

  • створений у серверному компоненті й переданий клієнтському як prop (основний сценарій у Next.js);
  • закешований бібліотекою (TanStack Query, роутер з завантажувачами даних);
  • створений поза рендером (в обробнику події, в завантажувачі маршруту).

2. Читання контексту:

function Toolbar({ showTheme }) {
  if (showTheme) {
    const theme = use(ThemeContext);   // useContext тут заборонений правилами хуків
    return <ThemeBadge theme={theme} />;
  }
  return null;
}

Що відрізняє use від хуків:

  • можна викликати умовно і в циклах;
  • але лише в компонентах і хуках - не в звичайних функціях і не в try/catch (помилки обробляє error boundary, а не catch);
  • не створює власного стану.

У серверних компонентах use для промісів не потрібен - там можна просто await. use - для клієнтських компонентів, що отримують проміс.

Чим це краще за useEffect для даних: немає проміжного стану «ще не завантажено» в кожному компоненті, немає гонитви запитів у ефектах, а стани завантаження й помилки декларативно задаються межами Suspense і error boundary.

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

Кожен рендер компонента - окремий виклик функції зі своїми значеннями props і стану. Функції, створені під час рендеру (обробники, колбеки ефектів), «запам'ятовують» значення цього рендеру. Якщо функція живе довше за рендер, вона бачить старі значення - це застаріле замикання (stale closure).

Класичний приклад - інтервал:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);   // count завжди 0 - замикання першого рендеру
    }, 1000);
    return () => clearInterval(id);
  }, []);   // ефект створено один раз

  return <p>{count}</p>;   // зупиняється на 1
}

Способи виправити:

1. Функціональне оновлення - не читати стан у замиканні взагалі:

setCount((c) => c + 1);

Найкращий варіант, коли нове значення залежить лише від попереднього.

2. Додати значення в залежності - ефект перезапуститься з новим замиканням:

useEffect(() => { /* ... */ }, [count]);

Коректно, але для інтервалу означає перестворення таймера щосекунди.

3. useEffectEvent - логіка, що має бачити свіжі значення, але не перезапускати ефект:

const onTick = useEffectEvent(() => setCount(count + step));
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);

4. useRef з поточним значенням - старий спосіб (ref оновлюється на кожному рендері, колбек читає ref.current). Працює, але useEffectEvent робить це явніше.

Де ще трапляється:

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

Профілактика: правило лінтера react-hooks/exhaustive-deps знаходить більшість застарілих замикань у ефектах і мемоізації. Попередження «missing dependency» - майже завжди справжня помилка, а не надмірна прискіпливість лінтера.

Докладніше в документації: Видалення залежностей ефекту

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

useTransition позначає оновлення стану як нетермінове (transition):

const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('posts');

function selectTab(next) {
  startTransition(() => {
    setTab(next);            // важкий рендер вкладки можна перервати
  });
}

{isPending && <Spinner />}

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

useDeferredValue - те саме, але для значення, яке ви отримуєте, а не встановлюєте:

const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SearchResults query={deferredQuery} />   // memo-компонент

Поле оновлюється одразу, а важкий список рендериться з «відкладеним» значенням у фоні. Щоб це працювало, SearchResults має бути обгорнутий у memo.

Чим це відрізняється від debounce: немає фіксованої затримки. На швидкому пристрої результат з'являється майже одразу, на повільному - React просто не блокує введення.

Обмеження: transitions не роблять рендер швидшим - лише не дають йому блокувати важливіше. Якщо компонент рендериться секунду, треба оптимізувати сам рендер (віртуалізація, мемоізація). У React 19 startTransition приймає й async-функції, а useActionState будується на тому ж механізмі.

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

Server Components (RSC) виконуються лише на сервері (або під час збирання) і ніколи не потрапляють у JavaScript-бандл клієнта. Вони можуть бути асинхронними й напряму читати базу, файли, секрети.

// Server Component (за замовчуванням у Next.js App Router)
async function PostPage({ id }) {
  const post = await db.posts.find(id);       // запит прямо в компоненті
  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton postId={id} />               {/* клієнтський острівець */}
    </article>
  );
}
'use client';
// Client Component: стан, ефекти, обробники подій
export function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>♥</button>;
}

Відмінності:

  • Server: немає useState, useEffect, обробників подій і API браузера. Зате нуль байтів JavaScript на клієнті й доступ до серверних ресурсів без окремого API.
  • Client: звичайний React з інтерактивністю. Позначаються директивою 'use client' - вона задає межу: файл і все, що він імпортує, потрапляє в клієнтський бандл.

Чим це відрізняється від SSR: SSR рендерить увесь застосунок у HTML на сервері, але потім увесь код завантажується в браузер і гідратується. RSC - це компоненти, код яких у браузер не потрапляє взагалі. Їх використовують разом із SSR.

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

  • Server Component може рендерити Client Component, але не навпаки (крім передачі через children).
  • Props від сервера до клієнта мають бути серіалізовними: функції передати не можна, хіба що серверні дії ('use server').
  • RSC потребують фреймворку з підтримкою (Next.js, React Router з RSC) - у звичайному Vite-застосунку їх немає.

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

Оновлення екрана в React проходить три кроки:

  1. Тригер - перший рендер (root.render) або зміна стану (setState) компонента чи його предка;
  2. Рендер - React викликає функції компонентів, щоб дізнатися, що має бути на екрані. Для оновлень порівнює новий результат з попереднім (reconciliation) і визначає мінімальні зміни;
  3. Коміт - React застосовує зміни до DOM, потім браузер відмальовує екран і виконуються ефекти.

Важливі наслідки:

  • рендер - чисте обчислення. Його можна перервати, відкласти, виконати повторно або двічі (StrictMode). Тому в тілі компонента не можна мутувати зовнішні змінні, робити запити чи змінювати DOM - лише обчислювати JSX з props і стану;
  • рендер ≠ зміна DOM. Якщо результат рендеру той самий, DOM не чіпається. Зайві рендери коштують процесорного часу на обчислення, але не на роботу з DOM;
  • у конкурентному режимі рендер нетермінового оновлення (transition) можна перервати заради термінового (введення в поле), а коміт завжди синхронний і неподільний - користувач не бачить «половини» оновлення.

Групування оновлень (batching). Кілька setState в одному обробнику дають один рендер:

function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  setItems([]);
  // один рендер після завершення обробника
}

З React 18 групування автоматичне скрізь: у таймерах, промісах, нативних обробниках подій. У React 17 і раніше воно працювало лише в обробниках подій React - setState у setTimeout давав окремий рендер на кожен виклик.

Черга оновлень. React обробляє оновлення по черзі: значення - замінює, функцію - викликає з результатом попереднього кроку:

setNumber(number + 5);      // замінити на 5
setNumber((n) => n + 1);    // 5 + 1
setNumber(42);              // замінити на 42
// результат 42

Коли потрібно оновити DOM негайно (виміряти щойно доданий елемент) - flushSync з react-dom. Це рідкісний виняток: він ламає групування й шкодить продуктивності.

Коли компонент перерендерюється: змінився його стан, перерендерився батько (навіть якщо props ті самі - якщо компонент не обгорнуто в memo і його не оптимізує React Compiler) або змінився контекст, який він читає.

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

<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

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

Інші рівні
Junior 33 Middle 34

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