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 — считайте его при рендере, эффект не нужен.