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, // знімок для серверного рендеру
);
}
Три аргументи:
subscribe(callback)- підписатися на зміни й повернути функцію відписки;getSnapshot()- поточне значення;getServerSnapshot()- значення для серверного рендеру й гідратації (на сервері немаєnavigator).
Ключові правила:
getSnapshotмає повертати те саме значення, доки дані не змінилися. Якщо він щоразу створює новий об'єкт (() => ({ ...store.state })), React вважатиме, що дані змінилися на кожному рендері, - нескінченний цикл і помилка. Повертайте збережене незмінне значення або примітив;subscribe- стабільна функція, оголошена поза компонентом (чи вuseCallback), інакше React перепідписуватиметься на кожному рендері;- оновлення з такого джерела не можна позначити як transition - вони завжди синхронні.
Хто вже використовує: Redux (useSelector), Zustand, TanStack Query та інші бібліотеки стану побудовані на ньому. У застосунку цей хук зазвичай потрібен для API браузера чи власного сховища.
Приклад із localStorage: підписка на подію storage (зміни з інших вкладок) плюс власна подія для змін у поточній вкладці - і стан синхронізований між вкладками.
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.
Кожен рендер компонента - окремий виклик функції зі своїми значеннями 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» - майже завжди справжня помилка, а не надмірна прискіпливість лінтера.