useEffect cleanup на практике: когда он критичен и как его не забыть
На собеседованиях я обычно спрашиваю: «расскажи, что делает функция, которую ты возвращаешь из 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 раз» и посмотреть, что станет с памятью, сетью и стейтом.
Что чаще всего забывают
Из моей практики, частая забывчивость идёт по такому списку:
- Подписка на
window.addEventListenerбез отписки. - Async-функция без флага cancellation или AbortController.
- Инициализация WebSocket/EventSource без
close. - Внешние библиотеки без
destroy. setIntervalс динамическими зависимостями, который пересоздаётся при каждом изменении.
Последний — отдельная категория. Когда у useEffect с интервалом есть зависимости, которые меняются часто, ты получаешь интервал, который постоянно сбрасывается и стартует заново. Внешне выглядит как «иногда срабатывает», на деле — постоянная пересборка таймеров.
Что запомнить
Cleanup — это не про «закрыть все ресурсы для красоты», а про защиту от трёх типов проблем: утечек памяти, накопления обработчиков и race conditions. Каждый раз, когда пишешь useEffect, мысленно прокрути сценарий с многократным запуском. Если что-то ломается — пиши cleanup. Если нет — оставь эффект чистым, без лишнего синтаксиса.
Strict Mode тебе друг. Если эффект сломался от двойного запуска в dev — это баг, который рано или поздно проявится в проде. Лучше пусть он проявится у тебя на машине во время разработки, а не у пользователя.