Коротка відповідь. Обидва хуки запускають код після рендера; різниця – в моменті відносно малювання екрана. useEffect виконується після того, як браузер намалював кадр, і не блокує його. useLayoutEffect – після оновлення DOM, але до малювання, синхронно. Тому 99% випадків – це useEffect; useLayoutEffect потрібен лише тоді, коли ти читаєш розміри/позиції з DOM і мусиш оновити стан до того, як користувач побачить кадр – інакше буде видиме мерехтіння.

Тайминг: що і коли виконується

Послідовність подій при оновленні компонента:

  1. React викликає компонент і рахує нове дерево.
  2. React вносить зміни в DOM.
  3. useLayoutEffect виконується синхронно – браузер ще нічого не намалював.
  4. Браузер малює кадр.
  5. 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.