Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
⚛️
React и TypeScript
Опубликовано:
19.10.2026
•
Обновлено:
19.10.2026

useEffect в React: зависимости, cleanup и отмена запросов без гонок

Тимофей Ищенко

Хук useEffect предназначен для синхронизации компонента React с внешними системами: DOM API, сетевыми запросами, таймерами или сторонними подписками. Непонимание механизмов замыканий (closures) и жизненного цикла эффекта приводит к трем классическим проблемам: утечкам памяти, устаревшим данным в состоянии (stale closure) и состоянию гонки (race conditions) при асинхронных запросах.

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


Как устроен жизненный цикл эффекта

Эффект в React — это не прямой аналог методов componentDidMount или componentDidUpdate. Это декларативный механизм синхронизации. Каждый запуск эффекта логически изолирован и привязан к конкретному рендеру со своими пропсами и состоянием.

Модель «подключение — очистка»

Жизненный цикл эффекта строится по принципу симметрии:

  1. Рендер и коммит: React отрисовывает JSX и обновляет DOM.
  2. Setup: React запускает функцию, переданную в useEffect.
  3. Перед следующим запуском (при изменении зависимостей): React сначала вызывает функцию очистки (cleanup) от предыдущего рендера со старыми значениями, и только потом выполняет новый setup с актуальными данными.
  4. Размонтирование (Unmount): React вызывает финальный cleanup.
Рендер 1  --->  Setup 1
                    | (изменились зависимости)
Рендер 2  --->  Cleanup 1  --->  Setup 2
                                     | (компонент удален из дерева)
Размонтирование                 ---> Cleanup 2

Поведение React StrictMode в режиме разработки

В режиме StrictMode (начиная с React 18) в среде разработки каждый компонент монтируется, размонтируется и снова монтируется:

Setup →Cleanup →Setup

Это поведение спроектировано для выявления эффектов, у которых отсутствует или некорректно реализована функция очистки. Если компонент подписывается на событие в setup, но не отписывается в cleanup, двойной вызов сразу удвоит количество слушателей и проявит утечку памяти.


Массив зависимостей: почему React сравнивает ссылки через Object.is

Второй аргумент useEffect — массив зависимостей. React проверяет каждое значение из массива между рендерами с помощью алгоритма Object.is.

Реактивные значения и правило полного массива зависимостей

Реактивными считаются любые значения, объявленные внутри компонента:

  • props
  • state
  • переменные и функции, вычисленные на базе props или state.

Если эффект использует реактивное значение, оно обязано присутствовать в массиве зависимостей. Игнорирование правила линтера react-hooks/exhaustive-deps приводит к чтению устаревших переменных из замыкания первого рендера.

Проблема нестабильных объектов и функций

Поскольку Object.is сравнивает объекты и функции по ссылке, их создание непосредственно в теле компонента приводит к повторному запуску эффекта на каждый рендер:

function UserProfile({ userId }) {
  // Ошибка: options создается заново при каждом рендере (новая ссылка в памяти)
  const options = { timeout: 1000 };

  useEffect(() => {
    fetchUserData(userId, options);
  }, [userId, options]); // Эффект будет срабатывать повторно при любом рендере компонента
}

Решения:

  1. Вынести неизменяемый объект или функцию за пределы компонента.
  2. Поместить объект внутрь самого эффекта, если он больше нигде не используется.
  3. Мемоизировать значения через useMemo или useCallback.

Ловушка Stale Closure: почему таймер видит старый state

Замыкание (closure) в JavaScript фиксирует значения переменных на момент своего создания. Если эффект с таймером запускается один раз (с пустым массивом []), его коллбэк навсегда сохраняет ссылку на состояние из первого рендера.

Проблема

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // count ВСЕГДА равен 0, так как взят из замыкания первого рендера
      setCount(count + 1); 
    }, 1000);

    return () => clearInterval(id);
  }, []); // Пустой массив: интервал не перезапускается, но значение застряло
}

Решение через функциональный updater

Чтобы обновить состояние на основе его предыдущего значения без включения самого состояния в зависимости, используется функциональная форма setState:

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // Читает актуальное значение из внутреннего хранилища React
  }, 1000);

  return () => clearInterval(id);
}, []); // Зависимость от count устранена, таймер стабилен

Очистка ресурсов (Cleanup): подписки, сокеты и слушатели событий

Функция очистки должна полностью нейтрализовать сайд-эффект, созданный в setup.

Правило симметричной отписки

Каждому действию в setup должно соответствовать противоположное действие в cleanup:

Действие в Setup Действие в Cleanup
addEventListener removeEventListener
setInterval / setTimeout clearInterval / clearTimeout
socket.subscribe() socket.unsubscribe() / socket.close()
observer.observe(node) observer.disconnect()
useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }

  window.addEventListener('resize', handleResize);

  // Обязательная симметричная очистка
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

Race Conditions в асинхронных запросах: два метода решения

Состояние гонки (race condition) возникает, когда пользователь быстро меняет параметры (например, переключает вкладки с ID 1 на ID 2).

  1. Отправляется запрос A для userId=1 (занимает 1500 мс).
  2. Пользователь выбирает userId=2, отправляется запрос B (занимает 300 мс).
  3. Запрос B завершается первым и отображает данные пользователя 2.
  4. Запрос A завершается позже и перезаписывает экран устаревшими данными пользователя 1.
Время ---->
Запрос A (ID=1): [========================] -> Запись в State (Ошибка!)
Запрос B (ID=2):    [======] -> Запись в State

Способ 1. Булев флаг отмены (ignore = true)

Флаг отслеживает, актуален ли еще текущий прогон эффекта. Сетевой запрос завершается в фоне, но его результат игнорируется.

useEffect(() => {
  let ignore = false;

  async function loadData() {
    const data = await fetchUserData(userId);
    if (!ignore) {
      setUser(data);
    }
  }

  loadData();

  return () => {
    ignore = true; // При смене userId или unmount старый результат будет проигнорирован
  };
}, [userId]);

Способ 2. AbortController и правильная обработка AbortError

Использование AbortController не просто игнорирует результат, а физически обрывает HTTP-соединение на уровне браузера, экономя трафик и ресурсы клиента.

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

  async function loadData() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal
      });
      const data = await response.json();
      setUser(data);
    } catch (error) {
      // Браузер генерирует AbortError при вызове abort()
      if (error.name !== 'AbortError') {
        setError(error);
      }
    }
  }

  loadData();

  return () => {
    controller.abort(); // Обрываем сетевой запрос
  };
}, [userId]);

Важно: Экземпляр AbortController одноразовый. После вызова abort() его signal навсегда переходит в состояние aborted=true. Для каждого нового запуска эффекта создается новый экземпляр new AbortController().


Практические кейсы: разбор 3 типовых ошибок с тестами

Кейс 1: Stale closure в setInterval

Ошибка

// CounterBad.jsx
export function CounterBad() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div data-testid="count">{count}</div>;
}

Исправление

// CounterGood.jsx
export function CounterGood() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div data-testid="count">{count}</div>;
}

Тест (React Testing Library + Jest Fake Timers)

import { render, screen, act } from '@testing-library/react';
import { CounterGood } from './CounterGood';

describe('CounterGood', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    act(() => {
      jest.runOnlyPendingTimers();
    });
    jest.useRealTimers();
  });

  test('корректно увеличивает счетчик каждую секунду', () => {
    const { unmount } = render(<CounterGood />);
    expect(screen.getByTestId('count')).toHaveTextContent('0');

    act(() => {
      jest.advanceTimersByTime(3000);
    });

    expect(screen.getByTestId('count')).toHaveTextContent('3');

    unmount();
    // Проверка, что после размонтирования таймер не тикает
    act(() => {
      jest.advanceTimersByTime(2000);
    });
  });
});

Кейс 2: Неосвобождённый window.addEventListener

Ошибка

// WindowTrackerBad.jsx
export function WindowTrackerBad() {
  useEffect(() => {
    const handleScroll = () => console.log('scroll');
    window.addEventListener('scroll', handleScroll);
    // Cleanup отсутствует: утечка памяти
  }, []);

  return <div>Tracker</div>;
}

Исправление

// WindowTrackerGood.jsx
export function WindowTrackerGood() {
  useEffect(() => {
    const handleScroll = () => console.log('scroll');
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);

  return <div>Tracker</div>;
}

Тест на вызов отписки при размонтировании

import { render } from '@testing-library/react';
import { WindowTrackerGood } from './WindowTrackerGood';

test('удаляет слушатель событий window при размонтировании', () => {
  const addSpy = jest.spyOn(window, 'addEventListener');
  const removeSpy = jest.spyOn(window, 'removeEventListener');

  const { unmount } = render(<WindowTrackerGood />);

  expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function));

  unmount();

  expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));

  addSpy.mockRestore();
  removeSpy.mockRestore();
});

Кейс 3: Состояние гонки при смене ID пользователя

Ошибка

// UserProfileBad.jsx
export function UserProfileBad({ userId, fetcher }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetcher(userId).then(res => setData(res));
  }, [userId, fetcher]);

  return <div data-testid="user-data">{data}</div>;
}

Исправление

// UserProfileGood.jsx
export function UserProfileGood({ userId, fetcher }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    let ignore = false;

    fetcher(userId).then(res => {
      if (!ignore) {
        setData(res);
      }
    });

    return () => {
      ignore = true;
    };
  }, [userId, fetcher]);

  return <div data-testid="user-data">{data}</div>;
}

Тест на предотвращение состояния гонки через rerender

import { render, screen, act } from '@testing-library/react';
import { UserProfileGood } from './UserProfileGood';

test('игнорирует ответ от медленного первого запроса в пользу второго', async () => {
  let resolveUser1;
  let resolveUser2;

  const mockFetcher = jest.fn((id) => {
    if (id === 1) {
      return new Promise(resolve => { resolveUser1 = resolve; });
    }
    return new Promise(resolve => { resolveUser2 = resolve; });
  });

  const { rerender } = render(<UserProfileGood userId={1} fetcher={mockFetcher} />);

  // Быстро меняем prop на userId = 2
  rerender(<UserProfileGood userId={2} fetcher={mockFetcher} />);

  // Запрос 2 завершается первым
  await act(async () => {
    resolveUser2('Данные пользователя 2');
  });

  expect(screen.getByTestId('user-data')).toHaveTextContent('Данные пользователя 2');

  // Медленный запрос 1 завершается позже
  await act(async () => {
    resolveUser1('Данные пользователя 1');
  });

  // Экран не должен перезаписаться старыми данными
  expect(screen.getByTestId('user-data')).toHaveTextContent('Данные пользователя 2');
});

Чек-лист: правила написания безопасных эффектов

  1. Не выключайте линтер exhaustive-deps: Все переменные из области видимости компонента, используемые в эффекте, должны быть указаны в массиве.
  2. Пишите симметричный cleanup: Любая подписка, таймер или слушатель DOM обязаны иметь обратное действие в возвращаемой функции.
  3. Используйте функциональный setState: Если новое состояние рассчитывается на основе старого, передавайте коллбэк prev => ..., чтобы не добавлять состояние в зависимости.
  4. Защищайте асинхронные операции: Используйте AbortController (для отмены запросов) или булев флаг ignore (для защиты состояния от устаревших промисов).
  5. Не делайте коллбэк эффекта асинхронным: Сигнатура useEffect(async () => ...) запрещена. Создавайте внутреннюю async функцию внутри тела эффекта.
  6. Выносите статичные структуры: Константы и чистые функции выносите за пределы компонента, чтобы Object.is не провоцировал лишние ререндеры.

Часто задаваемые вопросы (FAQ)

Почему нельзя передавать асинхронную функцию напрямую в useEffect(async () => ...)?

React ожидает, что функция setup вернет либо функцию очистки (cleanup), либо undefined. Асинхронная функция неявно возвращает Promise. Если вернуть промис, React не сможет зарегистрировать функцию очистки, что приведет к ошибкам выполнения и утечкам памяти.

В чём разница между флагом ignore и вызовом AbortController.abort()?

Флаг ignore предотвращает запись данных в состояние компонента, но сам запрос выполняется до конца и потребляет полосу пропускания. Метод controller.abort() обрывает соединение на транспортном уровне браузера. AbortController предпочтителен для тяжелых запросов, но для сторонних SDK без поддержки отмены ignore остается надежной альтернативой.

Зачем React в StrictMode вызывает эффекты дважды?

Двойной прогон (setup → cleanup → setup) в среде разработки выявляет эффекты с отсутствующей функцией очистки. Это гарантирует, что логика компонента устойчива к быстрым циклам монтирования/размонтирования, которые часто происходят при навигации и анимациях.

Нужно ли оборачивать каждую функцию в useCallback, если она вызывается в useEffect?

Нет. В большинстве случаев функцию лучше переместить внутрь тела самого useEffect. Оборачивать функцию в useCallback имеет смысл только тогда, когда она одновременно используется в нескольких эффектах или передается дочерним компонентам через пропсы.

Как реагировать на AbortError при использовании fetch?

При отмене запроса через AbortController метод fetch отклоняет промис с DOMException AbortError. Это штатное поведение, а не сбой приложения. В блоке catch необходимо проверять error.name === 'AbortError' и игнорировать такую ошибку, не записывая ее в состояние ошибок интерфейса.

Можно ли повторно использовать один и тот же AbortController в нескольких эффектах?

Нет. Как только вызван метод abort(), сигнал переходит в статус aborted навсегда. Все последующие сетевые запросы, использующие этот же сигнал, будут немедленно отклоняться. Для каждого запуска эффекта должен создаваться новый экземпляр контроллера.


Заключение

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

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

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