# useEffect vs useLayoutEffect: різниця, приклади й типові помилки

> Коли спрацьовує useEffect, а коли useLayoutEffect, чому мерехтить тултіп при вимірюванні DOM і як це полагодити. Робочі приклади, правила залежностей і cleanup, випадки, коли ефект взагалі не потрібен.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-09-24
- Категорія: React і Frontend
- Canonical: https://cookiesoftware.io/blog/useeffect-uselayouteffect

---

**Коротка відповідь.** Обидва хуки запускають код після рендера; різниця – в моменті відносно малювання екрана. `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](https://react.dev/reference/react/useLayoutEffect).

## Кейс, який лікує useLayoutEffect: тултіп, що мерехтить

Задача: тултіп має з'являтися над кнопкою, але якщо не влазить у вікно – під нею. Висоту тултіпа треба виміряти з реального DOM.

Зламаний варіант на `useEffect`:

```tsx
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` – і тултіп перестрибує вниз. На швидких машинах це «моргання», на повільних – помітний стрибок.

Фікс – одна заміна:

```tsx
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 => ...)](/blog/react-functional-state-update).

**Відсутній cleanup у підписок.** Кожен `addEventListener`, `setInterval`, `socket.on` без функції очищення – витік і дублікати обробників після повторного монтування:

```tsx
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize); // обов'язково
}, []);
```

**Гонки асинхронних запитів.** Відповідь на старий запит приходить після нової і затирає її. Мінімальний захист – прапорець або `AbortController` у cleanup; повний приклад зі станами loading/error є в розборі [live-coding задач із TypeScript](/blog/react-live-coding-typescript).

**Важка робота в useLayoutEffect.** Синхронний цикл на сотні елементів перед кожним кадром – і інтерфейс «дерев'яніє». Якщо код не впливає на те, як виглядає найближчий кадр – йому місце в `useEffect`.

## Коли ефект не потрібен взагалі

Найчастіша проблема з ефектами – не вибір між двома хуками, а те, що ефект зайвий. Два маркери:

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

```tsx
// ❌ стан + ефект
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](https://react.dev/learn/you-might-not-need-an-effect); на співбесіді посилання на нього і один приклад із власного досвіду рефакторингу звучать сильніше за будь-яке визначення.

## Питання зі співбесід

- **«Чим useLayoutEffect відрізняється від useEffect?»** – моментом виконання: до малювання (синхронно) проти після; звідси і сфера застосування.
- **«Наведи випадок, коли потрібен саме useLayoutEffect»** – вимірювання DOM із коригуванням верстки: тултіп/дропдаун із прикладу вище.
- **«Що буде, якщо все писати в useLayoutEffect?»** – коректно, але кожен ефект блокує кадр; на важкій логіці інтерфейс почне підлагувати.
- **«Ефект із порожнім масивом залежностей читає проп – що не так?»** – застаріле значення в замиканні; треба або додати залежність, або перепроєктувати ефект.
- **«Навіщо cleanup повертається з ефекту, а не пишеться поруч?»** – React викликає його перед наступним запуском ефекту і при розмонтуванні – саме тому підписка й відписка живуть в одному місці.

Повний контекст підготовки – у [гайді по React-співбесіді](/blog/react-interview-guide).

## Практичне завдання

Збери дропдаун-меню, яке відкривається вниз, але якщо до низу екрана менше 200 px – вгору. Спершу зроби на `useEffect` і подивись на мерехтіння (для наочності відкривай меню біля нижнього краю), потім переведи на `useLayoutEffect`. Наприкінці додай cleanup-підписку на `resize`, щоб позиція перераховувалась. Ця одна вправа закриває і тайминг, і вимірювання DOM, і cleanup.
