Коротка відповідь. Обидва хуки запускають код після рендера; різниця – в моменті відносно малювання екрана. useEffect виконується після того, як браузер намалював кадр, і не блокує його. useLayoutEffect – після оновлення DOM, але до малювання, синхронно. Тому 99% випадків – це useEffect; useLayoutEffect потрібен лише тоді, коли ти читаєш розміри/позиції з DOM і мусиш оновити стан до того, як користувач побачить кадр – інакше буде видиме мерехтіння.
Тайминг: що і коли виконується
Послідовність подій при оновленні компонента:
- React викликає компонент і рахує нове дерево.
- React вносить зміни в DOM.
useLayoutEffectвиконується синхронно – браузер ще нічого не намалював.- Браузер малює кадр.
useEffectвиконується – кадр уже на екрані.
Звідси головний компроміс: useLayoutEffect може виправити кадр до показу, але блокує малювання – важка логіка в ньому підвішує інтерфейс. Офіційна документація прямо радить починати з useEffect і переходити на layout-версію лише за видимої проблеми: react.dev/reference/react/useLayoutEffect.
Кейс, який лікує useLayoutEffect: тултіп, що мерехтить
Задача: тултіп має з'являтися над кнопкою, але якщо не влазить у вікно – під нею. Висоту тултіпа треба виміряти з реального DOM.
Зламаний варіант на useEffect:
import { useEffect, useRef, useState } from 'react';
export function TooltipFlickers({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
const [placeAbove, setPlaceAbove] = useState(true);
useEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (rect && rect.top < 0) {
setPlaceAbove(false); // ❌ кадр «зверху» вже показано – буде стрибок
}
}, []);
return (
<div ref={ref} style={{ bottom: placeAbove ? '100%' : 'auto', top: placeAbove ? 'auto' : '100%', position: 'absolute' }}>
{text}
</div>
);
}Що бачить користувач: один кадр тултіп рендериться зверху (і вилазить за екран), потім useEffect міряє, ставить placeAbove = false – і тултіп перестрибує вниз. На швидких машинах це «моргання», на повільних – помітний стрибок.
Фікс – одна заміна:
import { useLayoutEffect, useRef, useState } from 'react';
export function Tooltip({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
const [placeAbove, setPlaceAbove] = useState(true);
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (rect && rect.top < 0) {
setPlaceAbove(false); // ✅ повторний рендер станеться ДО малювання
}
}, []);
return (
<div ref={ref} style={{ bottom: placeAbove ? '100%' : 'auto', top: placeAbove ? 'auto' : '100%', position: 'absolute' }}>
{text}
</div>
);
}useLayoutEffect виміряв, стан оновився, React синхронно перерендерив – браузер намалював одразу правильний кадр. Користувач ніколи не бачить проміжного стану. Той самий патерн – для будь-якого «прочитай геометрію → підлаштуй верстку»: позиціювання дропдаунів, синхронізація скролу, вимірювання тексту.
Практичне правило вибору: якщо ефект читає layout (розміри, позиції, скрол) і від результату залежить те, що на екрані – layout-версія; усе інше – звичайний useEffect. Мережеві запити, підписки, аналітика, таймери, робота з localStorage – завжди useEffect: їм нема чого виправляти в кадрі, а блокувати малювання шкідливо.
Типові помилки із залежностями й cleanup
Ці помилки однакові для обох хуків і саме на них ловлять на співбесідах.
Брехня в масиві залежностей. Ефект читає userId, а в залежностях порожньо – після зміни користувача показуються старі дані. Правило просте: у масиві має бути все реактивне, що ефект читає. Якщо залежність «заважає» – проблема в дизайні ефекту, а не в лінтері; часто допомагає функціональне оновлення стану, детально розібране у статті про setState(prev => ...).
Відсутній cleanup у підписок. Кожен addEventListener, setInterval, socket.on без функції очищення – витік і дублікати обробників після повторного монтування:
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); // обов'язково
}, []);Гонки асинхронних запитів. Відповідь на старий запит приходить після нової і затирає її. Мінімальний захист – прапорець або AbortController у cleanup; повний приклад зі станами loading/error є в розборі live-coding задач із TypeScript.
Важка робота в useLayoutEffect. Синхронний цикл на сотні елементів перед кожним кадром – і інтерфейс «дерев'яніє». Якщо код не впливає на те, як виглядає найближчий кадр – йому місце в useEffect.
Коли ефект не потрібен взагалі
Найчастіша проблема з ефектами – не вибір між двома хуками, а те, що ефект зайвий. Два маркери:
Похідні дані. Стан «відфільтрований список», який ефект синхронізує з «списком + запитом», – це два джерела правди й гарантований розсинхрон. Похідне значення рахується прямо в рендері:
// ❌ стан + ефект
const [visible, setVisible] = useState(items);
useEffect(() => { setVisible(items.filter(matches)); }, [items, query]);
// ✅ просто обчислення
const visible = items.filter((item) => matches(item, query));Реакція на подію користувача. Логіка «після кліку відправ запит» належить обробнику кліку, а не ефекту, який стежить за станом-прапорцем «clicked».
Цьому присвячений цілий розділ документації – You Might Not Need an Effect; на співбесіді посилання на нього і один приклад із власного досвіду рефакторингу звучать сильніше за будь-яке визначення.
Питання зі співбесід
- «Чим useLayoutEffect відрізняється від useEffect?» – моментом виконання: до малювання (синхронно) проти після; звідси і сфера застосування.
- «Наведи випадок, коли потрібен саме useLayoutEffect» – вимірювання DOM із коригуванням верстки: тултіп/дропдаун із прикладу вище.
- «Що буде, якщо все писати в useLayoutEffect?» – коректно, але кожен ефект блокує кадр; на важкій логіці інтерфейс почне підлагувати.
- «Ефект із порожнім масивом залежностей читає проп – що не так?» – застаріле значення в замиканні; треба або додати залежність, або перепроєктувати ефект.
- «Навіщо cleanup повертається з ефекту, а не пишеться поруч?» – React викликає його перед наступним запуском ефекту і при розмонтуванні – саме тому підписка й відписка живуть в одному місці.
Повний контекст підготовки – у гайді по React-співбесіді.
Практичне завдання
Збери дропдаун-меню, яке відкривається вниз, але якщо до низу екрана менше 200 px – вгору. Спершу зроби на useEffect і подивись на мерехтіння (для наочності відкривай меню біля нижнього краю), потім переведи на useLayoutEffect. Наприкінці додай cleanup-підписку на resize, щоб позиція перераховувалась. Ця одна вправа закриває і тайминг, і вимірювання DOM, і cleanup.