Очистка эффектов и устаревшие замыкания
Каждую подписку, интервал, слушатель и fetch, запущенные эффектом, нужно разобрать в его return; эффект захватывает создавший его рендер — поэтому чини устаревшие замыкания верными зависимостями плюс функциональным обновлением или ref, а не глушением правила линтера.
Эффект — единственное место в React, где ты тянешься наружу из рендера: открываешь сокет, запускаешь таймер, вешаешь слушатель, отправляешь запрос. Рендер чист и одноразов — но то, что запускает эффект, нет. Оно переживает создавший его рендер, и, пока ты не дашь React способ это остановить, оно накапливается: новый интервал на каждое нажатие клавиши, слушатель, срабатывающий после размонтирования, устаревший запрос, перезаписывающий свежий.
Здесь прячутся два режима отказа, и это самые тонкие баги в React. Первый — отсутствие очистки: эффект что-то запускает и никогда не останавливает, поэтому он течёт и устраивает гонки при смене зависимостей. Второй — устаревшее замыкание: эффект захватил значения создавшего его рендера, поэтому действует по вчерашнему состоянию. Оба проходят CI зелёными и всплывают как нестабильный таймер или утечка в продакшене.
После этого урока ты можешь написать return-очистку для любого эффекта, запускающего подписку, интервал, слушатель или fetch (включая AbortController-abort); объяснить, почему эффект захватывает значения создавшего его рендера и как зависимости [] замораживают это замыкание навсегда; починить устаревшее замыкание в setInterval функциональным обновлением или ref вместо глушения exhaustive-deps; и назвать два противоположных режима отказа — заглушённое устаревшее замыкание против переполненного массива зависимостей, переподписывающегося на каждый рендер.
Что эффект запустил, то его очистка должна остановить — return-функция не декоративное украшение. React вызывает очистку перед повторным запуском эффекта (потому что сменилась зависимость) и ещё раз при размонтировании. Если её пропустить, старая подписка, интервал или слушатель продолжают работать рядом с новыми. Ментальная модель: установка и очистка идут парами, и в любой момент ровно одна подписка должна быть живой.
useEffect(() => {
const socket = createConnection(roomId);
socket.connect();
return () => socket.disconnect(); // парная к connect
}, [roomId]);Когда roomId меняется, React запускает очистку для старой комнаты, затем установку для новой. Убери return — и каждая смена комнаты течёт ещё одним открытым соединением, и каждое соединение всё ещё шлёт свои обработчики в компонент, который уже ушёл дальше.
Эффект захватывает значения создавшего его рендера — это и есть устаревшее замыкание, и это фича, а не баг. Функция, которую ты передаёшь в useEffect, замыкается над пропсами и состоянием того рендера. Если массив зависимостей не перечисляет значение, которое эффект читает, эффект продолжает использовать значение, замороженное на рендере, где он запускался в последний раз. С [] это самый первый рендер, навсегда.
function Poller({ onTick }: { onTick: (n: number) => void }) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// БАГ: `count` заморожен на 0 — это замыкание создано на
// первом рендере, а [] не даёт React сделать свежее.
setCount(count + 1); // всегда 0 + 1 → застрял на 1
}, 1000);
return () => clearInterval(id);
}, []); // лжёт: эффект читает `count`, но не перечисляет его
return <p>{count}</p>;
}Интервал работает вечно, но вечно вычисляет 0 + 1. Замыкание — это корректная семантика React; ложь — в зависимостях.
Чини устаревшее замыкание, убирая зависимость, а не пряча её: функциональным обновлением или ref для значений, которые ты только читаешь. Интервалу на самом деле не нужно текущее значение count — ему нужно инкрементировать то, какой count сейчас самый свежий. Функциональное обновление выражает именно это, и теперь эффект не читает никакого состояния, так что [] честен.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // React сам подаёт свежее значение
}, 1000);
return () => clearInterval(id);
}, []); // теперь честно — эффект не замыкается ни над чем реактивнымКогда внутри долгоживущего колбэка нужно именно прочитать свежее значение (а не просто преобразовать его) — скажем, свежайший пропс onTick, — запиши его в ref и читай ref.current, чтобы интервал никогда не пересоздавался, но всегда видел сегодняшнее значение:
const onTickRef = useRef(onTick);
useEffect(() => { onTickRef.current = onTick; }); // каждый рендер держим свежим
useEffect(() => {
const id = setInterval(() => onTickRef.current(Date.now()), 1000);
return () => clearInterval(id);
}, []); // интервал создан один раз; ref мостит к свежайшему пропсуFetch — тоже подписка: отменяй летящий запрос в очистке через AbortController, иначе быстрые зависимости устроят гонку с медленными ответами. Когда зависимость меняется быстрее, чем отвечает сеть, в полёте два запроса, и более медленный может разрешиться последним, перезаписав корректные данные. Очистка прерывает устаревший запрос, чтобы зафиксироваться смог только текущий.
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err); // игнорируем свой же abort
});
return () => ctrl.abort(); // отменяем устаревший запрос при смене userId/размонтировании
}, [userId]);Без abort переключение userId с 1 → 2 → 3 может оставить ответ пользователя 1 фиксирующимся после пользователя 3, и setUser, срабатывающий после размонтирования. Abort закрывает обе дыры — утечку и гонку — одной строкой.
Поле живого поиска, которое течёт и устраивает гонки, — починено в одном эффекте. Оно делает fetch на каждую смену query. Первая версия забывает и про очистку, и про ловушку устаревшего замыкания; вторая их закрывает.
До — без отмены и с debounce-таймером, который никогда не очищается:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [hits, setHits] = useState(0);
useEffect(() => {
// новый таймер на каждый рендер; старые таймеры никогда не очищаются → срабатывают многие
setTimeout(() => {
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((data) => {
setResults(data);
setHits(hits + 1); // устаревшее: `hits` заморожен на значении этого рендера
});
}, 300);
}, [query]); // нет return → нет очистки; таймеры + запросы копятся и устраивают гонки
return <Results items={results} count={hits} />;
}Набери «react» быстро — и получишь пять перекрывающихся таймеров, пять запросов, которые могут разрешиться не по порядку, и счётчик hits, застрявший около 1, потому что каждое замыкание читает одно и то же замороженное значение. После — debounce-таймер очищается, запрос прерывается, счётчик стал функциональным:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [hits, setHits] = useState(0);
useEffect(() => {
const ctrl = new AbortController();
const t = setTimeout(() => {
fetch(`/api/search?q=${query}`, { signal: ctrl.signal })
.then((r) => r.json())
.then((data) => {
setResults(data);
setHits((h) => h + 1); // функционально → нет зависимости от `hits`
})
.catch((e) => { if (e.name !== "AbortError") throw e; });
}, 300);
return () => { clearTimeout(t); ctrl.abort(); }; // оба ресурса освобождены
}, [query]); // честно: читает только `query`; setHits функционален
return <Results items={results} count={hits} />;
}Один return очищает ожидающий debounce-таймер и прерывает летящий запрос на каждое нажатие клавиши и при размонтировании; функциональный setHits убирает единственное состояние, которое читал эффект, так что [query] теперь полный и корректный список зависимостей.
▸Частая ошибка
Карьерообразующая ошибка — заглушить предупреждение: ты видишь React Hook useEffect has a missing dependency: 'count', добавляешь // eslint-disable-next-line react-hooks/exhaustive-deps и отгружаешь устаревшее замыкание. Правило линтера почти никогда не ошибается — оно говорит тебе, что эффект читает значение, которого нет в зависимостях. Починка в том, чтобы сделать чтение честным (функциональное обновление, ref или реально перечислить зависимость), а не заткнуть гонца. Отключённая строка exhaustive-deps — это баг с комментарием, извиняющимся за себя.
▸Граничные случаи
Противоположная переправка так же неверна: в ужасе перед устаревшими замыканиями ты впихиваешь каждое упомянутое значение в массив зависимостей — включая объект config или функцию onChange, пересоздаваемую инлайн на каждом рендере. Теперь эффект разбирается и переподписывается на каждом рендере: сокет постоянно переподключается, интервал сбрасывается, fetch перевыстреливает в цикле. Лекарство — стабилизировать зависимость (функциональное обновление, чтобы её убрать, useCallback/useMemo для функций и объектов или ref для значений только-на-чтение), а не урезать массив ложью. Цель — честные зависимости, которые ещё и стабильны, а не самый длинный массив и не самый короткий.
Эффект запускает `setInterval(() => setCount(count + 1), 1000)` с зависимостями `[]`, и счётчик застревает на 1. PR коллеги добавляет `count` в массив зависимостей, чтобы это починить. В чём senior-критика?
Эффекты — это место, где React касается внешнего мира, и этот мир за собой не убирает. Сопрягай каждую установку с очисткой: disconnect для подписки, clearInterval для интервала, removeEventListener для слушателя, AbortController.abort для fetch — React запускает return перед каждым повторным запуском и при размонтировании, поэтому отсутствие очистки означает утечки и гонки в момент смены зависимости. Вторая половина — устаревшее замыкание: эффект захватывает значения создавшего его рендера, поэтому массив зависимостей, опустивший читаемое значение, замораживает это значение (с [] — навсегда). Чини, делая чтение честным — функциональным обновлением, чтобы убрать зависимость, или ref, чтобы прочитать свежее значение из долгоживущего колбэка, — а не отключением exhaustive-deps, которое просто отгружает баг с извинением. И не переправляй в противоположный отказ: переполненный массив зависимостей переподписывается на каждый рендер. Цель — зависимости, которые одновременно честны и стабильны, чтобы каждый эффект устанавливался один раз на реальное изменение и всегда действовал по сегодняшним значениям.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.