lenec ru

← все посты

useEffect cleanup на практике: когда он критичен и как его не забыть

19K

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

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

Что вообще делает cleanup

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

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

Самый каноничный пример. Без clearInterval у тебя при каждом ре-маунте плодятся таймеры, и через час открытой вкладки страница начинает греться.

Случай 1: подписки на события

Класический. Если подписался на window, document или другой долгоживущий объект — обязан отписаться.

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

Что происходит без cleanup: ты ушёл со страницы, компонент размонтировался, но обработчик остался висеть на window. Через десять навигаций у тебя десять обработчиков. На сайте с долгим SPA-сценарием это превращается в утечку памяти, которая в DevTools видна не сразу.

Я для таких случаев держу простой принцип: каждое addEventListener в эффекте требует removeEventListener в cleanup. Это правило проверяет даже ESLint react-hooks/exhaustive-deps в строгом режиме.

Случай 2: подписки на observables и WebSocket

В одном проекте у нас был чат на WebSocket. Каждый раз, когда пользователь переключал тред, открывался новый сокет. Мы забыли cleanup. Через полчаса тестирования у тестировщика на машине было открыто 47 соединений. Сервер взвыл.

useEffect(() => {
  const ws = new WebSocket(`wss://api.example.com/chat/${threadId}`);
  ws.onmessage = (event) => handleMessage(event.data);
  return () => ws.close();
}, [threadId]);

То же самое с RxJS, MQTT, любыми long-living соединениями. Если эффект что-то открыл — cleanup обязан это закрыть.

Случай 3: race condition в async-эффектах

Этот случай я считаю самым коварным. Cleanup тут не закрывает соединение, а защищает от гонки.

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

  fetchUser(userId).then((user) => {
    if (!cancelled) {
      setUser(user);
    }
  });

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

Без флага cancelled сценарий такой: пользователь быстро кликает по списку юзеров. Запрос за userId=1 уходит, потом сразу за userId=2. Сервер по какой-то причине отвечает на 1 позже, чем на 2. В состояние записывается сначала юзер 2, потом юзер 1, и в UI ты видишь не того, кого выбирал. Классическая гонка.

Альтернатива на современных API — AbortController:

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

  fetch(`/api/user/${userId}`, { signal: ac.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((e) => {
      if (e.name !== "AbortError") throw e;
    });

  return () => ac.abort();
}, [userId]);

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

Случай 4: таймеры и RAF

Аналогично интервалам — все setTimeout, requestAnimationFrame, queueMicrotask с долгим хвостом нужно очищать.

useEffect(() => {
  let raf = 0;
  const loop = () => {
    update();
    raf = requestAnimationFrame(loop);
  };
  raf = requestAnimationFrame(loop);
  return () => cancelAnimationFrame(raf);
}, []);

Если этого не сделать, цикл анимации продолжит крутиться даже после размонтирования компонента. На странице, где это анимация в карточке списка, ты получишь столько RAF-циклов, сколько раз пользователь скроллил.

Случай 5: внешние библиотеки и DOM-манипуляции

Если ты в эффекте инициализируешь внешнюю библиотеку (карта, редактор, какой-нибудь графический движок) — её надо корректно убить в cleanup. Иначе DOM засоряется, обработчики живут вечно, библиотека продолжает что-то делать в фоне.

useEffect(() => {
  if (!ref.current) return;
  const editor = createEditor(ref.current, { value });
  editor.on("change", onChange);
  return () => {
    editor.off("change", onChange);
    editor.destroy();
  };
}, []);

В реальной жизни большинство таких библиотек предоставляют метод destroy или dispose. Если не предоставляют — это плохая библиотека.

Случай, когда cleanup НЕ нужен

Симметричное правило: если в эффекте ты ничего не открыл и не подписался — cleanup не нужен. Не пиши пустой return () => {}, это просто шум.

// ✅ нормально, без cleanup
useEffect(() => {
  document.title = `${unread} непрочитанных`;
}, [unread]);

Хотя в этом конкретном примере я бы вообще не использовала useEffect. Заголовок — производное от стейта, и его можно вычислять прямо в рендере. Но это уже другая статья (которая, кстати, у меня в очереди).

Strict Mode: почему cleanup срабатывает дважды в dev

В React 18+ в Strict Mode каждый эффект в development вызывается дважды: монтирование, размонтирование с cleanup, монтирование заново. Это сделано специально, чтобы ты заметил, если у тебя cleanup написан неправильно.

Если эффект работает корректно один раз и сломался при двойном вызове — у тебя баг. Например, ты в монтировании увеличиваешь счётчик в глобальной переменной, а в cleanup не уменьшаешь. После первого монтирования счётчик 1, после cleanup всё ещё 1, после второго монтирования 2. Strict Mode тебе это показывает на первой же странице.

Общее правило, которое я держу в голове

Когда смотрю на любой useEffect, задаю себе один вопрос: что должно произойти, если этот эффект запустится 100 раз подряд?

Если ничего страшного — таймеров не плодится, обработчиков не накапливается, гонки нет — cleanup можно не писать. Если что-то сломается — пиши cleanup и проверь его в Strict Mode.

Это правило экономит мне время на ревью. Не нужно вспоминать каждое API и его особенности — достаточно представить сценарий «эффект запустился 100 раз» и посмотреть, что станет с памятью, сетью и стейтом.

Что чаще всего забывают

Из моей практики, частая забывчивость идёт по такому списку:

  1. Подписка на window.addEventListener без отписки.
  2. Async-функция без флага cancellation или AbortController.
  3. Инициализация WebSocket/EventSource без close.
  4. Внешние библиотеки без destroy.
  5. setInterval с динамическими зависимостями, который пересоздаётся при каждом изменении.

Последний — отдельная категория. Когда у useEffect с интервалом есть зависимости, которые меняются часто, ты получаешь интервал, который постоянно сбрасывается и стартует заново. Внешне выглядит как «иногда срабатывает», на деле — постоянная пересборка таймеров.

Что запомнить

Cleanup — это не про «закрыть все ресурсы для красоты», а про защиту от трёх типов проблем: утечек памяти, накопления обработчиков и race conditions. Каждый раз, когда пишешь useEffect, мысленно прокрути сценарий с многократным запуском. Если что-то ломается — пиши cleanup. Если нет — оставь эффект чистым, без лишнего синтаксиса.

Strict Mode тебе друг. Если эффект сломался от двойного запуска в dev — это баг, который рано или поздно проявится в проде. Лучше пусть он проявится у тебя на машине во время разработки, а не у пользователя.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.