Почему useEffect вызывает баги: разбор типичных ошибок и безопасных альтернатив

Разбираем, почему useEffect часто становится источником багов в React: stale closure, лишние зависимости, гонки запросов, Strict Mode, утечки подписок и случаи, когда эффект вообще не нужен.

21 марта 2026 г.17 минLexicon Team

Введение

useEffect считается простым хуком ровно до первого большого экрана с API, фильтрами, подписками и несколькими источниками состояния. На маленьком примере эффект выглядит безобидно: что-то загрузить, что-то подписать, что-то записать в localStorage. На реальном проекте именно вокруг useEffect чаще всего появляются бесконечные циклы, устаревшие данные, двойные запросы в dev, утечки подписок и трудноуловимые баги, которые воспроизводятся только при быстрой навигации.

Проблема не в том, что API хука большое. Проблема в том, что useEffect стоит на границе между декларативным React и недекларативным внешним миром. В одном месте сходятся тайминг commit-фазы, массив зависимостей, замыкания JavaScript, асинхронные операции и обязательный cleanup. Если разработчик использует эффект как универсальное место для "логики после рендера", ошибки почти неизбежны.

Для общего понимания, так же полезно будет ознакомиться с разбором жизненного цикла React-компонента, материалом про Strict Mode и статьей когда компонент перерисовывается. Без этой базы useEffect легко воспринимать как замену старых lifecycle-методов, хотя на практике это инструмент синхронизации, а не "контейнер для побочной логики".

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Почему именно useEffect так легко ломается

Короткий ответ: эффект связывает React с системами, которые живут по другим правилам. Сам рендер компонента должен быть чистым и предсказуемым. Эффект, наоборот, делает побочную работу после commit: запускает запрос, открывает подписку, трогает DOM, пишет в storage, ставит таймер.

Из этого следуют четыре источника риска:

  1. Эффект выполняется не во время render, а после commit, поэтому ошибка часто проявляется не там, где вы ее ищете.
  2. Логика эффекта читает значения из конкретного рендера, то есть легко получить stale closure, если зависимости описаны неверно.
  3. У любой внешней операции есть жизненный цикл: setup, обновление, отмена, очистка. Если cleanup неполный, баг накапливается со временем.
  4. Асинхронные операции не знают, какой рендер был последним, поэтому старый ответ может победить новый.

Именно поэтому useEffect хорошо работает только тогда, когда у него узкая и честная ответственность: синхронизировать компонент с одной внешней системой. Как только эффект начинает одновременно "считать derived state", дергать API, писать логи и обновлять локальное состояние, он превращается в точку пересечения нескольких причин изменения UI.

Архитектура проблемы: где рождаются баги

Полезно смотреть на useEffect как на мост между двумя мирами.

Слева находится React:

  • props
  • state
  • context
  • render
  • commit

Справа находится внешний мир:

  • сеть
  • браузерные события
  • таймеры
  • localStorage
  • сторонние библиотеки
  • imperatively managed DOM

Поток обычно такой:

  1. Компонент рендерится с некоторым набором значений.
  2. React коммитит UI.
  3. useEffect запускает синхронизацию наружу.
  4. Наружная система позже возвращает результат.
  5. Эффект или callback обновляет состояние.
  6. Компонент рендерится заново.

Узкие места этой схемы:

  • Эффект может стартовать со старыми значениями.
  • Внешняя система может вернуть результат в "неправильном" порядке.
  • Cleanup может не успеть или быть неполным.
  • Новый рендер может создать новый callback, а подписка останется на старом.

Типичная точка отказа в продакшене выглядит так: пользователь быстро меняет фильтр, старый запрос завершается позже нового, экран показывает устаревшие данные, а команда тратит часы на поиск "рандомного" бага. Формально проблема не в API-сервере и не в React. Проблема в том, что у эффекта не была описана стратегия отмены и разрешения гонок.

Когда useEffect не нужен вообще

Многие баги начинаются не с неправильного эффекта, а с лишнего эффекта. Если задача решается без синхронизации с внешним миром, useEffect чаще всего не нужен.

СценарийЧастая ошибкаЧто лучшеПочему безопаснее
Посчитать значение из props и stateКласть результат в setState внутри эффектаВычислить прямо в renderНет второго источника истины
Реакция на клик пользователяЗапускать действие через флаг и эффектВыполнить код в обработчике событияЛогика остается рядом с причиной
Сброс внутреннего состояния формыДелать setState по зависимостямИспользовать key или явный resetМеньше неявных переходов
Загрузка данных для экранаПисать fetch вручную в каждом компонентеИспользовать data-fetching слой или хотя бы изолированный хукЕсть кэш, отмена, дедупликация
Синхронизация derived stateДублировать проп в локальный state через эффектНормализовать модель данныхМеньше рассинхронизации

Практическое правило простое: если после удаления useEffect код становится понятнее, а поведение остается корректным, эффект был лишним. Особенно часто это видно в коде "копируем props.value в локальный state, а потом держим их синхронными". Такая схема почти всегда создает скрытую рассинхронизацию.

Код-пример: stale closure и пропущенная зависимость

Один из самых частых багов связан не с сетью, а с устаревшим замыканием.

import { useEffect, useState } from "react";

export function SearchBox({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    const id = setTimeout(async () => {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await response.json();
      setResults(data.items);
    }, 300);

    return () => clearTimeout(id);
  }, []);

  return <div>{results.length}</div>;
}

Снаружи кажется, что это debounce-поиск. На практике эффект видит только тот query, который был во время первого рендера, потому что массив зависимостей пустой. Пользователь меняет строку поиска, UI обновляется, но сетевой запрос остается привязан к старому значению.

Проблема здесь не формальная, а архитектурная: эффект объявляет, что ни от чего не зависит, хотя фактически зависит от query. В продакшене это дает странный симптом: в интерфейсе видно новый ввод, а сетевой слой работает по старым данным.

Исправление:

import { useEffect, useState } from "react";

export function SearchBox({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    const id = setTimeout(async () => {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await response.json();
      setResults(data.items);
    }, 300);

    return () => clearTimeout(id);
  }, [query]);

  return <div>{results.length}</div>;
}

Но даже это еще не финальная версия. Мы исправили stale closure, однако не защитились от гонки запросов.

Код-пример: гонка запросов и перезапись нового UI старым ответом

Этот баг особенно часто появляется на поиске, фильтрах и автокомплите.

import { useEffect, useState } from "react";

type User = { id: string; name: string };

export function UsersList({ search }: { search: string }) {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      const response = await fetch(`/api/users?search=${encodeURIComponent(search)}`, {
        signal: controller.signal,
      });

      const data = await response.json();
      setUsers(data.items);
    }

    load().catch((error) => {
      if (error.name !== "AbortError") {
        throw error;
      }
    });

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

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Почему эта версия безопаснее:

  • новый запрос отменяет старый;
  • cleanup соответствует жизненному циклу эффекта;
  • состояние обновляется только из актуального запроса;
  • логика синхронизации локализована в одном месте.

Если не предусмотреть отмену, старый медленный ответ может прийти после нового быстрого и затереть актуальный UI. В метриках это часто видно как "плавающие" жалобы пользователей без стабильного воспроизведения. В логах сеть выглядит здоровой, а баг живет в клиентской координации запросов.

Для крупного проекта ручной fetch в useEffect быстро становится дорогим в поддержке. Поэтому тема естественно пересекается со статьями про производительность React и memoization: проблема редко в одном запросе, чаще в накоплении лишних ререндеров, повторных запросов и нестабильных зависимостей.

Почему Strict Mode делает useEffect "хуже", хотя на самом деле помогает

Когда разработчик впервые видит двойной вызов эффекта в dev, возникает ощущение, что баг создал React. На деле Strict Mode только подсвечивает уже существующую проблему: эффект не является идемпотентным или плохо очищается.

Что обычно ломается:

  • аналитика отправляется два раза;
  • websocket или event listener подключается повторно;
  • таймер не снимается;
  • запрос без отмены дублируется;
  • локальный флаг "выполнить только один раз" маскирует ошибку вместо исправления.

Сильный инженерный вывод здесь такой: если эффект невозможно безопасно запустить по схеме "setup, затем cleanup, снова setup", значит у вас либо плохой cleanup, либо неверно выбрана точка запуска логики. Часть действий вообще не должна жить в эффекте. Например, отправка события "пользователь нажал кнопку" должна происходить в обработчике клика, а не в useEffect, который следит за флагом isSubmitted.

Разбор производительности: как useEffect делает приложение медленнее

Сам по себе useEffect не означает деградацию. Узкое место появляется, когда эффект:

  • повторно запускается из-за нестабильных объектов и функций в dependencies;
  • вызывает лишний setState, создавая дополнительный рендер;
  • дублирует данные, которые можно вычислить во время render;
  • триггерит сеть чаще, чем нужно;
  • связывает несколько независимых задач в один большой блок.

Типичный пример преждевременной оптимизации выглядит так: разработчик боится "дорогих вычислений", выносит derived state в useEffect, делает setFilteredItems, а затем получает дополнительный рендер на каждое изменение фильтра. На коротком списке это бессмысленно, на длинном становится источником дрожащего UI и более сложной отладки.

Когда оптимизация оправдана:

  • есть реальная дорогая операция;
  • измерения через profiler показывают узкое место;
  • эффект действительно синхронизирует внешнюю систему;
  • зависимостями можно управлять честно и предсказуемо.

Когда оптимизация преждевременна:

  • эффект нужен только чтобы "не считать в render";
  • вы не мерили стоимость проблемы;
  • derived state можно выразить обычным вычислением;
  • логика становится менее прозрачной, чем была.

Production pitfalls: три типичных проблемы

1. Утечка подписок

Симптом:

  • после переходов по страницам одно и то же событие приходит несколько раз;
  • растет память вкладки;
  • дублируются логи аналитики.

Причина:

  • cleanup не удаляет listener;
  • cleanup использует не ту ссылку на callback;
  • эффект смешивает несколько подписок и чистит только часть.

Профилактика:

  • один эффект на одну внешнюю подписку;
  • симметричный setup/cleanup;
  • стабильные ссылки там, где этого требует API.

2. Дублирование запросов при смене фильтров

Симптом:

  • сеть "шумит" даже при обычном вводе;
  • UI мигает между старым и новым состоянием;
  • сервер видит кратный рост запросов.

Причина:

  • нет debounce;
  • нет отмены старого запроса;
  • в dependencies попадает новый объект на каждом рендере.

Профилактика:

  • стабилизировать входные данные;
  • отменять устаревшие операции;
  • по возможности выносить загрузку данных в специализированный слой.

3. Рассинхронизация props и local state

Симптом:

  • форма показывает старое значение после смены сущности;
  • часть UI обновилась, часть осталась от предыдущего объекта;
  • воспроизводится только при быстрой навигации.

Причина:

  • дублирование источника истины через useEffect(() => setState(prop), [prop]);
  • попытка "поддерживать синхронность" вручную.

Профилактика:

  • не копировать данные без необходимости;
  • использовать key для reset-сценариев;
  • проектировать state-модель так, чтобы источник истины был один.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks.

Начать

Практики, которые снижают риск багов

  1. Пишите эффект как контракт синхронизации с одной внешней системой. Если задач две, вероятно, нужно два эффекта или вообще другой подход.
  2. Делайте зависимости честными. Борьба с eslint-plugin-react-hooks обычно означает, что код пытается скрыть реальную зависимость.
  3. Держите setup и cleanup симметричными. Если открыли подписку, покажите в том же блоке, где ее закрываете.
  4. Не храните derived state, который можно вычислить в render.
  5. Переносите пользовательские действия в обработчики событий, а не в эффект, следящий за флагами.
  6. Для загрузки данных предпочитайте изолированный кастомный хук или библиотеку, которая уже решает отмену, кэш и дедупликацию.
  7. Проверяйте эффекты под быстрыми переходами, повторными монтированиями и dev Strict Mode. Именно там всплывают реальные дефекты жизненного цикла.

Частые ошибки

  1. Использовать useEffect как современный аналог "любого кода после рендера".
  2. Подавлять предупреждение про зависимости вместо исправления модели данных.
  3. Копировать props в локальный state и пытаться синхронизировать их вручную.
  4. Делать сетевой запрос без отмены и считать, что проблема только в dev-дублях.
  5. Складывать подписки, таймеры и setState в один длинный эффект.
  6. Лечить проблему флагом в useRef, который "запускает только один раз".

Как отвечать на интервью

Хороший ответ на вопрос "почему useEffect вызывает баги" обычно строится так:

  1. Сначала обозначить назначение: useEffect нужен для синхронизации с внешними системами после commit, а не для любой логики подряд.
  2. Затем назвать источник сложности: зависимости, замыкания, cleanup и асинхронность живут в одном месте.
  3. После этого привести 2 практических дефекта: stale closure и race condition.
  4. Закончить критерием выбора: если задачу можно решить без эффекта, это обычно безопаснее.

Сильная формулировка звучит примерно так:

useEffect часто вызывает баги не потому, что хук плохой, а потому, что через него React-компонент начинает зависеть от внешнего мира. Ошибки появляются в местах, где не совпадают жизненные циклы: старое замыкание читает не те данные, старый запрос перезаписывает новый UI, cleanup не снимает подписку, а лишний effect дублирует источник истины. Чем уже ответственность эффекта и чем чаще задачу можно решить без него, тем стабильнее код.

Подготовка к React-собеседованию без заучивания шаблонов

Разберите useEffect, Strict Mode, ререндеры и производительность так, как это спрашивают в сильных командах: через причинно-следственные связи, production-сценарии и реальный код.

Начать подготовку

FAQ

Можно ли полностью перестать использовать useEffect?

Нет. Он остается правильным инструментом для подписок, интеграций с браузерными API, ручной синхронизации со сторонними библиотеками и части сценариев загрузки данных. Цель не убрать useEffect любой ценой, а не превращать его в универсальную свалку логики.

Почему eslint требует добавлять зависимости, хотя без них "все работает"?

Потому что код "работает" только в простом сценарии. Линтер напоминает, что эффект использует значения из текущего рендера. Если их скрыть из массива зависимостей, баг проявится позже: при обновлении пропсов, быстрой навигации, Strict Mode или изменении порядка событий.

Нужно ли всегда выносить fetch из useEffect в библиотеку?

Не всегда. Для небольшого экрана ручной код допустим, если есть отмена, обработка ошибок и понятный жизненный цикл. Но по мере роста продукта библиотека окупается быстро, потому что снимает целый класс повторяющихся дефектов.

Почему setState внутри useEffect часто выглядит подозрительно?

Потому что это нередко признак второго источника истины. Если состояние можно вычислить из уже существующих данных, дополнительный setState только добавляет рендер, рассинхронизацию и больше точек для ошибки.

Чем useEffect связан с багами производительности?

Связь прямая: лишние эффекты создают лишние рендеры, нестабильные зависимости перезапускают дорогую работу, а лишние запросы перегружают сеть и UI. Баг производительности здесь обычно не отдельная категория, а следствие плохой модели синхронизации.

Итоги

useEffect вызывает баги там, где код пытается сделать слишком много сразу: синхронизировать сеть, продублировать состояние, реагировать на события и чинить архитектурные пробелы через "постфактум-логику". Чем ближе эффект к одной конкретной внешней системе, тем он безопаснее. Чем чаще вы можете убрать эффект совсем, тем предсказуемее становится компонент.

Если свести тему к одному правилу, оно звучит так: не спрашивайте "как написать useEffect", сначала спрашивайте "нужен ли здесь useEffect вообще". В React это одно из самых выгодных архитектурных решений по соотношению простоты и количества предотвращенных багов.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Автор

Lexicon Team

Читайте также