useEffect: зависимости и очистка

«Мы открыли вкладку Network, а запросы уходят бесконечным потоком». Почему useEffect зацикливается, что чинит массив зависимостей и зачем он вызывается дважды.

useEffect — это способ синхронизировать компонент с внешним миром (сеть, таймеры, подписки, DOM вне React). Не «функция, которая запускается после рендера», а синхронизация: эффект запускается заново каждый раз, когда меняется то, от чего он зависит, и обязан уметь за собой убирать.

Вопрос: почему запросы уходят бесконечно?

import { useState, useEffect } from 'react';

function UserCard({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then(setUser);
  }); // ← массива зависимостей нет

  return <div>{user?.name}</div>;
}

Цикл замыкается так: эффект без массива зависимостей выполняется после каждого рендера → запрос → setUser → новое состояние → новый рендер → эффект снова → запрос… Пока открыта вкладка, сервер получает шквал запросов. Это самый частый «боевой» баг новичков и любимый вопрос интервьюеров, потому что он проверяет сразу и понимание рендера, и понимание зависимостей.

Три формы второго аргумента

ЗаписьКогда запускается эффект
useEffect(fn)после каждого рендера — почти всегда ошибка, если внутри есть setState
useEffect(fn, [])один раз при монтировании (в dev+StrictMode — дважды, см. ниже)
useEffect(fn, [userId])при монтировании и всякий раз, когда userId изменился по Object.is

Фикс нашего примера — добавить [userId]. Тогда запрос уходит при монтировании и при смене пользователя, а setUser больше не запускает эффект повторно, потому что user не попал в зависимости.

Ловушка вторая: объект в зависимостях

Здесь ловят уже мидлов. Массив зависимостей выглядит корректным, но эффект всё равно срабатывает на каждом рендере:

function Search({ query }) {
  const options = { includeArchived: false }; // новый объект на КАЖДОМ рендере

  useEffect(() => {
    loadResults(query, options);
  }, [query, options]); // options всегда «новый» → эффект перезапускается
}

React сравнивает зависимости через Object.is, то есть по ссылке. Литерал объекта, массива, функции создаётся заново при каждом вызове компонента — значит, ссылка каждый раз другая. Лечится тремя способами: занести объект внутрь эффекта (лучший вариант), разложить на примитивы ([query, includeArchived]) или, если объект приходит извне, стабилизировать его через useMemo.

Как это работает под капотом

Сравнение зависимостей — это поэлементный Object.is старого и нового массива. Никакой глубокой проверки содержимого нет и не будет: она была бы дорогой и непредсказуемой. Убедиться легко:

const a = { includeArchived: false };
const b = { includeArchived: false }; // такое же содержимое, другая ссылка

console.log('объекты с одинаковыми полями:', Object.is(a, b));
console.log('одинаковые строки:', Object.is('user-42', 'user-42'));

const prevDeps = [a, 'user-42'];
const nextDeps = [b, 'user-42'];
const same = prevDeps.every((dep, i) => Object.is(dep, nextDeps[i]));

console.log('зависимости совпали?', same, '→', same ? 'эффект пропущен' : 'эффект запустится снова');

Результат:

объекты с одинаковыми полями: false
одинаковые строки: true
зависимости совпали? false → эффект запустится снова

Отсюда правило: в зависимостях хорошо себя ведут примитивы (строки, числа, булевы), а объекты, массивы и функции требуют либо стабилизации, либо переноса внутрь эффекта.

Функция очистки: отписка и отмена запроса

Эффект может вернуть функцию — React вызовет её перед следующим запуском эффекта и при размонтировании компонента. Без очистки получаются подписки-призраки, таймеры, которые стреляют в мёртвый компонент, и утечки памяти.

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener('resize', onResize);
  return () => window.removeEventListener('resize', onResize);
}, []);

Но самый интересный случай — гонка запросов (race condition). Пользователь быстро переключается: userId 1 → 2. Уходят два запроса; порядок ответов сервер не гарантирует. Если ответ по первому пользователю придёт позже, он затрёт данные второго, и на экране окажется чужой профиль. Спросить про это любят как раз на позициях мидл+.

useEffect(() => {
  const controller = new AbortController();
  let ignore = false;

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then((data) => {
      if (!ignore) setUser(data); // ответ от устаревшего эффекта игнорируем
    })
    .catch((e) => {
      if (e.name !== 'AbortError') setError(e);
    });

  return () => {
    ignore = true;        // защита от гонки
    controller.abort();   // и заодно рвём висящий запрос
  };
}, [userId]);

Флаг ignore живёт в замыкании конкретного запуска эффекта. Когда userId меняется, React сначала вызывает очистку старого запуска (ignore = true), и его ответ, даже придя последним, уже никого не тронет.

Почему эффект вызывается дважды в StrictMode

В режиме разработки внутри <StrictMode> React намеренно монтирует компонент, запускает эффект, вызывает очистку и запускает эффект ещё раз. Это не баг и не «двойной рендер из-за медленного дева» — это детектор: если после такого цикла приложение сломалось (подписка задвоилась, счётчик уехал, запрос ушёл дважды), значит, ваш эффект не идемпотентен и не умеет за собой убирать. В продакшен-сборке двойного вызова нет.

Ответ, который нравится интервьюерам: «Лечить это надо не флагом useRef, который глушит второй запуск, а честной функцией очистки — именно её отсутствие StrictMode и подсвечивает».

Частые ошибки на собеседовании

  • Убрать зависимость, чтобы «починить» цикл, и заглушить ESLint комментарием eslint-disable-next-line react-hooks/exhaustive-deps. Так вы не убираете цикл, а прячете рассинхронизацию: эффект будет работать со старыми props.
  • Пустой массив как «запускать один раз». [] означает «эффект ни от чего не зависит», а не «выполнить единожды». Если внутри используется userId, он обязан быть в списке.
  • Забыть очистку для таймеров, addEventListener, WebSocket, подписок стора.
  • Считать двойной вызов в StrictMode багом React и обходить его рефом.
  • Использовать эффект для производного состояния: useEffect(() => setFullName(first + ' ' + last), [first, last]). Это лишний рендер — такое значение просто считается во время рендера: const fullName = first + ' ' + last. Умение сказать «этому коду эффект вообще не нужен» отличает сильного кандидата.

Итоги-шпаргалка

  • Нет массива → эффект после каждого рендера. setState внутри такого эффекта = бесконечный цикл.
  • [] → один запуск при монтировании; [deps] → при изменении зависимостей по Object.is.
  • Зависимости сравниваются по ссылке: объекты, массивы и функции-литералы «новые» каждый рендер.
  • Функция очистки вызывается перед каждым перезапуском эффекта и при размонтировании.
  • Гонку запросов гасим флагом ignore и/или AbortController.
  • Двойной вызов в StrictMode — dev-детектор отсутствующей очистки, а не баг.
  • Если значение выводится из props и state — считайте его при рендере, эффект не нужен.
Проверьте себя
1. useEffect без второго аргумента вызывает внутри себя setState. Что произойдёт?
AЭффект выполнится один раз при монтировании
BБесконечный цикл: setState → рендер → эффект → setState
CReact сам обнаружит проблему и выполнит эффект только дважды
DНичего, React пропустит рендер благодаря батчингу
2. Зачем React в StrictMode (в режиме разработки) монтирует компонент, запускает эффект, вызывает очистку и запускает эффект ещё раз?
AЧтобы прогреть кеш и ускорить первый рендер
BЭто известный баг, его отключают через useRef
CЧтобы подсветить эффекты без корректной функции очистки — неидемпотентные эффекты сломаются заметно
DЧтобы дважды выполнить запрос и сравнить ответы сервера