Skip to main content

Як працює useEffect у React?

useEffect - це хук React, який запускає побічні ефекти після того, як компонент відрендерився, не блокуючи браузер від малювання екрану.

Теорія

TL;DR

  • useEffect виконується після рендеру, не під час. Спочатку з'являється UI, потім запускається ефект.
  • Порожній [] = один раз при монтуванні. Без deps = при кожному рендері. [value] = коли value змінюється.
  • Завжди повертай функцію очищення, якщо ефект створює підписку, таймер або слухач подій.
  • Потрібна робота після рендеру? useEffect. Чиста логіка стану? useState або reducer.
  • Аналогія: знак "не турбувати" в готелі - вішаєш після заселення, і прибирання (побічний ефект) приходить лише коли ти змінив знак (deps).

Швидкий приклад

jsx
import { useState, useEffect } from 'react'; function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `Count: ${count}`; // Виконується після рендеру }, [count]); // Повторно запускається лише коли count змінюється return <button onClick={() => setCount(count + 1)}>Count: {count}</button>; } // Заголовок оновлюється після кожного кліку, але не на кожен рендер загалом.

Після рендеру React оновлює заголовок документа. Якщо count не змінився, ефект пропускається. Передбачувано і без сюрпризів.

Головна відмінність від lifecycle-методів класів

У класових компонентах були три окремі методи: componentDidMount, componentDidUpdate і componentWillUnmount. useEffect об'єднує їх в один хук. Повернене значення відповідає за cleanup (unmount), масив deps - за умовні повторні запуски (did update), а порожній масив - за запуск лише при монтуванні. Одна ментальна модель замість трьох.

Коли використовувати

  • Завантажити дані при монтуванні: [] в якості deps.
  • Повторно завантажувати при зміні пропсу: додай цей пропс у deps [userId].
  • Підписка або таймер: повертай функцію очищення.
  • Синхронізація зовнішнього стану (заголовок документа, localStorage, аналітика): включай значення, яке синхронізуєш, у deps.
  • Не потрібен useEffect для: чистих обчислень, похідного стану, синхронного читання DOM перед paint. Для останнього є useLayoutEffect.

Як React планує виконання useEffect

React запускає ефекти після того, як браузер намалював екран. Це відбувається під час commit-фази fiber reconciliation: React завершує мутації DOM, браузер робить paint, і тільки потім через планувальник асинхронно запускаються ефекти. Саме цим useEffect відрізняється від useLayoutEffect, який запускається синхронно після мутацій DOM, але до paint.

Cleanup виконується перед наступним ефектом (якщо deps змінились) і при розмонтуванні. React відстежує ефекти через пов'язаний список хуків на fiber-вузлі, тому хуки мають викликатись в одному і тому ж порядку при кожному рендері.

Типові помилки

Помилка 1: відсутній масив deps

jsx
useEffect(() => { fetchData(); // Виконується при кожному рендері });

Без масиву deps ефект запускається після кожного рендеру. Якщо fetchData оновлює стан, отримаємо нескінченний цикл. Додай [] або конкретні значення, від яких справді залежить ефект.

Помилка 2: немає очищення

jsx
useEffect(() => { const timer = setInterval(tick, 1000); // Не вистачає: return () => clearInterval(timer); }, []);

Таймер продовжує працювати після розмонтування компонента. Це одна з найпоширеніших витоків пам'яті в React-додатках. Завжди прибирай інтервали, підписки і слухачі подій.

Помилка 3: об'єкт у масиві deps

jsx
const config = { id: 1 }; useEffect(() => { api(config); }, [config]); // Новий об'єкт при кожному рендері

Об'єкти порівнюються за посиланням. { id: 1 } !== { id: 1 }, тому ефект запускається при кожному рендері незалежно від значень. Рішення: залежь від примітивного значення.

jsx
useEffect(() => { api({ id: config.id }); }, [config.id]); // Стабільний примітив

Помилка 4: нескінченний цикл

jsx
function BadCounter() { const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); // count в deps викликає рендер, який знову запускає ефект }, [count]); }

Оновлення стану, який є у deps, = нескінченні рендери. Якщо потрібен лічильник, що сам себе інкрементує, використовуй функціональне оновлення без deps:

jsx
useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); // Без stale closure, deps не потрібні }, 1000); return () => clearInterval(id); }, []);

Де зустрічається у реальних проєктах

  • React Query / TanStack Query: використовує useEffect внутрішньо для початкових запитів перед тим, як кеш бере управління.
  • Next.js: useEffect + useRouter для редіректів після гідратації (серверний код не знає про роутер).
  • Framer Motion: вимірює DOM за допомогою useEffect перед запуском анімацій.
  • Redux Toolkit: синхронізація стану з подіями window.focus для відстеження активності вкладки.
  • Паттерн з AbortController для відміни запиту:
jsx
useEffect(() => { const abort = new AbortController(); fetch(`/api/user/${userId}`, { signal: abort.signal }) .then(res => res.json()) .then(setUser); return () => abort.abort(); }, [userId]);

Follow-up питання

Q: Коли саме виконується useEffect відносно рендеру?


A: Після того, як браузер намалював екран. React завершує зміни в DOM, браузер робить paint, і тільки потім асинхронно запускаються ефекти. Користувач бачить оновлений UI ще до того, як будь-який ефект виконається.

Q: В чому різниця між порожнім [] і відсутністю deps?


A: Порожній [] запускає ефект один раз після першого рендеру. Без deps - після кожного рендеру. Це принципово різна поведінка, і їх плутанина призводить до багів.

Q: Як скасувати запит при розмонтуванні компонента?


A: Використовуй AbortController. Створи його всередині ефекту, передай його signal у fetch, а в cleanup виклич abort.abort(). Це запобігає оновленню стану після розмонтування.

Q: Навіщо ESLint-правило exhaustive-deps?


A: Щоб уникнути stale closures (застарілих замикань). Якщо ефект читає змінну, але не вказує її в deps, він захоплює значення з першого рендеру і більше не оновлює його. Лінтер змушує явно вказувати залежності.

Q: Як ефекти поводяться в concurrent mode React 18 під час transitions?


A: Ефекти все одно виконуються після commit, навіть коли рендери переривались через transitions. React може рендерити компонент кілька разів до commit, але ефекти запускаються лише один раз за commit. Тому React 18 у Strict Mode двічі викликає ефекти в dev-режимі - щоб виявити баги в ефектах, які не є ідемпотентними.

Приклади

Базовий: синхронізація заголовка зі станом

jsx
import { useState, useEffect } from 'react'; function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `Count: ${count}`; }, [count]); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>; }

Ефект спрацьовує один раз після монтування (встановлює заголовок "Count: 0"), а потім при кожному кліку. Якщо компонент перерендерився з іншої причини без зміни count - ефект пропускається.

Середній рівень: завантаження даних з очищенням

jsx
import { useState, useEffect } from 'react'; function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { const abort = new AbortController(); fetch(`/api/user/${userId}`, { signal: abort.signal }) .then(res => res.json()) .then(setUser) .catch(err => { if (err.name !== 'AbortError') console.error(err); }); return () => abort.abort(); // Скасовуємо запит якщо userId зміниться до завершення }, [userId]); return <div>{user ? user.name : 'Loading...'}</div>; }

Коли userId змінюється, React спочатку виконує cleanup (скасовує поточний запит), а потім стартує новий. Без race condition і без оновлення стану від застарілих відповідей.

Просунутий рівень: уникнення stale closures через useRef

Бачив, як це ловить навіть досвідчених розробників. Ефект захоплює значення callback у момент свого запуску, а не визначення.

jsx
import { useState, useEffect, useRef } from 'react'; function SearchInput({ onSearch }) { const [query, setQuery] = useState(''); const onSearchRef = useRef(onSearch); // Тримаємо ref актуальним при кожному рендері, deps не потрібні useEffect(() => { onSearchRef.current = onSearch; }); useEffect(() => { if (!query) return; const timer = setTimeout(() => { onSearchRef.current(query); // Завжди використовує останній callback }, 300); return () => clearTimeout(timer); }, [query]); // Повторно запускається тільки при зміні query return <input value={query} onChange={e => setQuery(e.target.value)} />; }

Перший ефект (без deps) оновлює onSearchRef.current при кожному рендері. Другий ефект читає з ref, тому ніколи не застаріває, але повторно запускається лише при зміні query.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?