Головна / Статті

Аудит useEffect: десять поширених помилок, їхні симптоми та ефективні рішення

Впізнайте десять найпоширеніших помилок useEffect у React — від відсутності функції очищення та застарілих замикань до умов змагання та синхронізації пропсів — та застосуйте відповідні рішення.

Видавець: REACTAPP.TOP8 хв читання1919 слів

Джерела: 10 React useEffect Mistakes That Are Secretly Breaking Your App in 2026

React 19, React Compiler та Server Components перенесли багато функцій з useEffect, проте цей хук досі є джерелом багатьох складних проблем у React: витоки ресурсів, застарілі дані, мерехтіння та цикли відрендеровування. Більшість з цих проблем виникає через десять поширених помилок. Нижче для кожної з них наведена несправна версія, симптоми та спосіб виправлення, а також чек-лист для перевірки власних ефектів.

Принцип кожного виправлення: useEffect існує для синхронізації компонента з чимось поза React, таким як таймер, сокет або API браузера.

1. Забуття функції очищення

Таймери, підписки та слухачі подій, створені в ефекті, продовжують працювати після повторного відрендеровування чи видалення компонента, якщо їх не припинити. У цьому випадку інтервал ніколи не скасовується:

// ❌ Broken: No cleanup
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  // Missing: return () => clearInterval(timer);
}, []);

Симптомами є витікання інтервалів та дублювання слухачів. Під час розробки режим Strict Mode навмисно підключає, від’єднує та знову підключає компоненти, щоб виявити цю проблему: без належного очищення утворюється два інтервали, і лічильник збільшується двічі на секунду. У продакшені витік виникає щоразу, коли компонент знову підключається. Поверніть функцію, яка скасує це налаштування:

// ✅ Fixed: Always clean up
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []);

2. Використання порожнього масиву залежностей для позначення „виконати один раз“

Порожній масив не означає „під час підключення“ у якомусь особливому сенсі. Він повідомляє React, що ефект не залежить ні від чого, тому він ніколи не виконується знову. Якщо ефект читає пропси або стан, ці значення залишаються незмінними на рівні першого відображення:

// ❌ Broken: Stale closure
function ChatRoom({ roomId }) {
  useEffect(() => {
    connectToRoom(roomId);
    return () => disconnectFromRoom(roomId);
  }, []); // roomId changes, but effect never re-runs
}

Коли змінюється roomId, компонент залишається у початковій кімнаті. Вказайте всі реактивні значення, які використовує ефект; правило лінтингу react-hooks/exhaustive-deps виявляє це.

// ✅ Fixed: Include real dependencies
useEffect(() => {
  connectToRoom(roomId);
  return () => disconnectFromRoom(roomId);
}, [roomId]);

Тепер React від’єднується від попередньої кімнати та під’єднується до нової щоразу, коли змінюється roomId.

3. Залежність від об’єктів, які створюються заново при кожному рендерингу

Залежності порівнюються за посиланням. Об’єкт або масив, створений під час рендерингу, є новим посиланням щоразу, тому ефект, який від нього залежить, перезапускається після кожного рендерингу:

// ❌ Broken: New object every render
function Parent() {
  const config = { timeout: 5000 }; // New reference every time

  useEffect(() => {
    fetchData(config);
  }, [config]); // Runs every single render
}

Стабілізуйте посилання. Якщо значення ніколи не змінюється, визначте його один раз поза компонентом. Якщо воно формується з пропсів або стану, обгорніть його у useMemo із відповідними залежностями. Наведений нижче фрагмент демонструє обидві опції поруч; на практиці ви маєте обрати одну з них:

// ✅ Fixed: Stable reference
const CONFIG = { timeout: 5000 }; // Defined outside component
function Parent() {
  const config = useMemo(() => ({ timeout: 5000 }), []);

  useEffect(() => {
    fetchData(config);
  }, [config]);
}

Часто ще простіше: використовуйте примітиви (timeout замість config) та створюйте об’єкт всередині ефекту. React Compiler мемоїзує багато значень у коді, який він компілює, але не об’єкти, що надходять зі некомпільованого коду чи бібліотек, тому не покладайтеся на це для правильності ефекту.

4. Дозволення асинхронним відповідям конкурувати між собою

Коли залежність швидко змінюється, як у випадку з полем пошуку чи швидкою заміною вкладок, кілька запитів можуть виконуватися одночасно, і вони не обов’язково завершуються у порядку:

// ❌ Broken: Race condition
useEffect(() => {
  fetch(`/api/search?q=${query}`).then(res => res.json()).then(setResults);
}, [query]);

Якщо користувач вводить „react“, а потім „react hooks“, відповідь на запит про „react“ може надійти останньою та перезаписати правильні результати. Рішення полягає у скасуванні застарілого запиту під час очищення. Об’єкт AbortController скасовує операцію fetch, а виниклий AbortError ігнорується, щоб він не розглядався як справжня помилка:

// ✅ Fixed: Abort in-flight requests
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

  return () => controller.abort();
}, [query]);

Для інтерфейсів пошуку існують додаткові нюанси, про які йдеться у статті як усунути ситуації конкуренції, які не можна вирішити за допомогою технології debouncing.

5. Зберігання похідних значень у стані

Якщо значення можна обчислити з наявних параметрів чи стану, йому не потрібен власний стан чи ефект:

// ❌ Broken: Redundant state + effect
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

Кожна зміна відображається двічі: спочатку з застарілим значенням fullName, що створює два джерела інформації. Обчислюйте значення під час відображення, використовуючи useMemo лише для справді ресурсоємких операцій:

// ✅ Fixed: Compute during render
const fullName = `${firstName} ${lastName}`;// Or for expensive computations
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

Зауважте, що у фрагменті показано два варіанти; оголошення fullName двічі в одному області видимості є синтаксичною помилкою, тому залиште лише одне. Більш загальну модель розглядається у безпечнішому альтернативному підході до синхронізації стану з useEffect.

6. Створення циклу оновлення

Ефект, який змінює стан, також постійно залежить від самого графіка виконання:

// ❌ Broken: Infinite loop
useEffect(() => {
  setCount(count + 1);
}, [count]);

Оновлення запускає відрендерування, відрендерування змінює значення count, змінене значення count знову запускає ефект, і так далі. React зрештою зупиняє цей цикл за допомогою помилки „Максимальна глибина оновлень перевищена“. Функціональне оновлення усуває необхідність читання значення count всередині ефекту, тому його більше не потрібно вказувати як залежність:

// ✅ Fixed: Functional update + correct dependencies
useEffect(() => {
  const timer = setTimeout(() => {
    setCount(c => c + 1); // Functional update avoids dependency
  }, 1000);
  return () => clearTimeout(timer);
}, []); // No count dependency needed

Зауважте, що це змінює поведінку: лічильник тепер збільшується лише один раз, через секунду після ініціалізації. Для лічильника, який постійно оновлюється, використовуйте setInterval із функцією очищення, як у прикладі помилки 1. Будь-яке використання setState у ефекті має містити умову, яка гарантуватиме його завершення.

7. Повне пропускання масиву залежностей

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

// ❌ Broken: Runs on every render
useEffect(() => {
  console.log('Effect running');
  performHeavyComputation(data);
});

Компілятор React зменшує кількість непотрібних переробок, але не робить обчислення всередині ваших ефектів дешевшими, тому ефект, який виконує важкі операції без захисту, залишається вузьким місцем. Оголосіть справжню залежність:

// ✅ Fixed: Explicit dependencies
useEffect(() => {
  performHeavyComputation(data);
}, [data]);

8. Обробка подій користувача в ефектах

Ефекти виконуються тому, що щось було відображено, а не тому, що користувач щось зробив. Аналітика, пов’язана з прапорцем стану, запускається щоразу, коли цей прапорець змінюється, з будь-якої причини:

// ❌ Broken: Event logic in effect
useEffect(() => {
  if (isModalOpen) {
    trackEvent('modal_opened');
  }
}, [isModalOpen]);

Ця подія фіксується під час ініціалізації, якщо модальне вікно вже відкрите, коли батьківський елемент змінює свої параметри або коли відновлюється стан, а не лише тоді, коли користувач натискає. Логіку взаємодії потрібно розмістити в обробнику, який виконує цю взаємодію:

// ✅ Fixed: Event handler
function handleOpenModal() {
  setIsModalOpen(true);
  trackEvent('modal_opened');
}

Якщо ви можете назвати дію користувача, яка має запустити код, вона повинна знаходитися в обробнику.

9. Завантаження даних за допомогою простого ефекту

Оскільки серверні компоненти та завантажувачі фреймворку обробляють початкові дані у багатьох додатках, отримання даних на стороні клієнта за допомогою ефектів стає менш частим. Коли ви все ж отримуєте дані на клієнті, примітивний ефект є крихким:

// ❌ Broken: Raw fetch in effect
useEffect(() => {
  fetch('/api/user').then(r => r.json()).then(setUser);
}, []);

У нього немає стану завантаження, обробки помилок, усунення дублікатів запитів, кешування, а також, як показує помилка 4, захисту від конкурентних ситуацій. Бібліотека для отримання даних обробляє все це:

// ✅ Fixed: Use a data-fetching library
const { data: user, isLoading, error } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000,
});

TanStack Query та SWR є звичайними варіантами. Без них потрібно створити власний хук із обробкою стану завантаження, помилок та припинення операції замість спорадичних ефектів отримання даних.

10. Копіювання пропсів у стан та синхронізація їх

Компонент, який отримує початкове значення, копіює його у стан, а потім використовує ефект для відтворення змін пропсів, фактично працює проти себе:

// ❌ Broken: Prop-to-state sync
function EmailInput({ initialEmail }) {
  const [email, setEmail] = useState(initialEmail);

  useEffect(() => {
    setEmail(initialEmail); // Sync prop to state
  }, [initialEmail]);

  return <input value={email} onChange={e => setEmail(e.target.value)} />;
}

Щоразу, коли батьківський елемент надає нове значення initialEmail, введені користувачем дані мовчки перезаписуються після додаткового відтворення зі старим значенням. Виберіть один підхід: зробіть компонент повністю керованим або залиште його некерованим та навмисно скидайте його, змінюючи його key, що дасть React нову інстанцію зі свіжим станом:

// ✅ Fixed: Controlled or fully uncontrolled
// Option 1: Fully controlled — no internal state
function EmailInput({ email, onChange }) {
  return <input value={email} onChange={onChange} />;
}
// Option 2: Fully uncontrolled with key
function EmailInput({ initialEmail }) {
  const [email, setEmail] = useState(initialEmail);
  return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
// Usage: Reset by changing key
<EmailInput key={userId} initialEmail={user.email} />

Як React 19 змінює ситуацію

Останні версії React роблять ці помилки як більш помітними, так і менш необхідними:

  • Режим Strict Mode, який дозволяє подвійне встановлення ефектів лише під час розробки, дозволяє рано виявити відсутність або неправильну обробку даних.
  • Компілятор React автоматично мемоїзує компоненти та багато значень, що зменшує випадкові повторні відтворення, але не виправляє логіку ефектів.
  • Компоненти сервера беруть на себе більшу частину початкового завантаження даних, залишаючи ефекти переважно для роботи лише на клієнті, такої як підписки, анімації та API браузера.
  • API use може читати обіцянки та контекст під час відображення, що усуває деякі причини для використання ефектів з асинхронними значеннями.
  • Перелік перевірок перед об’єднанням для ефектів

    Перед випуском ефекту перевірте:

    • Кожна підписка, таймер та слухач мають відповідну процедуру очищення.
    • Масив залежностей містить усі реактивні значення, які читає ефект.
    • Залежності у вигляді об’єктів та масивів є стабільними або замінені на примітивні залежності.
    • Асинхронна робота скасовується або ігнорується при зміні залежностей.
    • Нічого з того, що обчислюється в ефекті, не можна просто отримати під час відображення.
    • Жодне оновлення стану не може знову запустити той самий ефект без умови зупинки.
    • Логіка, яку запускає користувач, знаходиться у обробниках подій.
    • Завантаження даних, коли це можливо, здійснюється за допомогою компонента сервера, завантажувача фреймворку чи бібліотеки для отримання даних.
    • Кожен елемент стану має лише одне джерело істини.

    Підсумок

    Майже кожна проблема з useEffect виникає через використання цього хука не для зовнішньої синхронізації. Похідні значення мають знаходитися у функції відображення, дії користувача — у обробниках подій, дані сервера — у шарі даних, а стан, який можна скинути, — за допомогою key. Справжні підписки, що залишаються, потребують точних залежностей та процедур очищення. Часто найкращим результатом аудиту є видалення такого ефекту.