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

Senior: питання на співбесіді з теми «Хуки»

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

5 питань

Власний хук - функція з назвою на 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» - майже завжди справжня помилка, а не надмірна прискіпливість лінтера.

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