useEffect — это синхронизация, а не lifecycle
useEffect синхронизирует компонент с внешней системой — это не lifecycle. Deps сравниваются поэлементно через Object.is; cleanup идёт перед каждым перезапуском и при размонтировании. StrictMode монтирует дважды, вскрывая отсутствующий cleanup; гонки fetch лечит AbortController.
Страница поиска ушла в прод в четверг. В пятницу пришёл тикет с записью экрана: пользователь печатает «invoice», делает паузу, допечатывает «invoices overdue» — а список результатов показывает совпадения для «invoice». Он перепечатывает запрос; то же самое. Баг воспроизводился только на медленном соединении, поэтому каждый инженер на быстром офисном Wi-Fi дважды закрыл его как «не воспроизводится». Эффект делал fetch на каждое нажатие клавиши, но сеть не обещает отвечать по порядку: запрос для «invoice» шёл 900 мс, запрос для «invoices overdue» — 200 мс, и медленный устаревший ответ пришёл последним, перезаписав свежие данные через setResults. Cleanup-функции не было — никто не сказал старому запросу: «твой рендер мёртв, отбой». В том же компоненте жил второй латентный баг, которого никто не замечал: в разработке StrictMode монтировал его дважды, накапливались два WebSocket-соединения, и команда «починила» это, выключив StrictMode. Оба бага — один и тот же баг: отношение к useEffect как к «коду, который выполняется при монтировании», вместо контракта синхронизации, у которого есть старт и стоп.
Синхронизация, а не lifecycle
Ментальная модель, порождающая баги, — классово-компонентная: «useEffect с [] — это componentDidMount, useEffect с deps — componentDidUpdate». Модель, порождающая корректный код, другая: эффект описывает, как держать внешнюю систему синхронизированной с текущими props и state компонента. Внешняя система — это всё за пределами вывода рендера React: сетевой запрос, WebSocket, setInterval, слушатель событий на window, SDK аналитики, сторонний виджет. Тело эффекта говорит, как начать синхронизацию; возвращаемая cleanup-функция — как её остановить. React выполняет эту пару столько раз, сколько нужно: по разу на «значения, которые читает эффект, изменились», а не по разу на воображаемую фазу жизненного цикла.
Отсюда два механических факта. Первый: эффекты выполняются после коммита — React рендерит, мутирует DOM, браузер отрисовывает, и только потом срабатывает эффект. Эффект никогда не блокирует рендер, которому принадлежит. Второй: у каждого рендера своё замыкание эффекта. Когда query равен «invoice», выполняющийся эффект захватил "invoice"; на следующем рендере с «invoices overdue» React не мутирует старый эффект — он сносит его (cleanup) и запускает совершенно новый, захвативший новое значение. Нет одного долгоживущего эффекта, «видящего» меняющиеся значения; есть последовательность короткоживущих, каждый заморожен над породившим его рендером. Баги устаревших замыканий — ровно то, что случается в борьбе с этим: когда через массив deps вы говорите React, что эффект не читает значение, которое он на самом деле читает.
Массив зависимостей, поэлементно
Массив deps — не ручка конфигурации, а декларация: «этот эффект читает вот эти реактивные значения». На каждом рендере после первого React сравнивает новый массив с предыдущим поэлементно, через Object.is. Любой элемент отличается → cleanup старого эффекта, запуск нового. Все равны → пропустить. Object.is для объектов — сравнение ссылок, поэтому такой код перезапускает эффект на каждом рендере:
function Chart({ series }) {
// ❌ options — новая идентичность объекта на каждом рендере,
// поэтому Object.is(prevOptions, options) всегда false —
// эффект сносит и пересоздаёт график на каждом рендере.
const options = { animate: true, series };
useEffect(() => {
const chart = mountChart(node, options);
return () => chart.destroy();
}, [options]);
}Лечение — зависеть от примитивов внутри ([series]), конструировать объект внутри эффекта или мемоизировать его. Обратный провал хуже: врать линтеру. Убрать query из deps, потому что «мне нужно только на маунте», не заставляет эффект читать стабильное значение — это заставляет эффект вечно работать с query первого рендера. Правило exhaustive-deps — не стилистическая прихоть; каждое его подавление — устаревшее замыкание, ждущее кейса воспроизведения. Если вы не хотите, чтобы эффект перезапускался при изменении значения, честные ответы такие: вынести значение из компонента, сделать его ref-ом, если оно действительно нереактивно, или перестроить код так, чтобы эффект его не читал. Пустой массив [] — это утверждение «этот эффект не читает реактивных значений», а не заявка на поведение «только при маунте».
У эффекта deps [filters], где filters — объектный литерал, создаваемый в теле компонента на каждом рендере. Компонент перерисовывается 30 раз, пока пользователь скроллит. Сколько раз выполнится эффект?
Cleanup и зачем StrictMode монтирует вас дважды
Тайминг cleanup точен, и его стоит запомнить: cleanup рендера N выполняется перед эффектом рендера N+1 и ещё раз при размонтировании. Жизнь одного экземпляра эффекта взята в скобки: установка со значениями рендера N → (позже) снос со значениями рендера N. Замыкание сноса видит старые props — и это ровно то, что нужно: сносится старая подписка, старый id комнаты, старый запрос, который надо отменить. Эффект корректен, когда установка и cleanup симметричны: connect/disconnect, subscribe/unsubscribe, start/abort. Если cleanup невозможно написать — обычно это знак, что эффект делает что-то, не являющееся синхронизацией (считает производное состояние, реагирует на «события»), и его место в другом коде.
Именно для этого существует дев-режимный двойной маунт StrictMode (режим строгих проверок React, включается обёрткой <React.StrictMode>). Начиная с React 18, StrictMode монтирует каждый компонент, выполняет эффекты, немедленно выполняет cleanup и монтирует снова. Это зонд корректности: если ваш эффект — симметричная пара установка/cleanup, лишний цикл невидим. Если двойной маунт удваивает WebSocket-соединения, дважды стреляет аналитикой или утекает interval — StrictMode не создал этот баг, он его вскрыл: тот же цикл снос/установка происходит в проде при каждой смене deps, при ремоунте во время навигации и при fast-refresh в разработке. Выключить StrictMode (команда из Hook сделала ровно это) — не починить асимметрию; это убрать единственное, что о ней сообщает.
▸Почему это работает
Зачем React намеренно монтирует всё дважды в разработке? Потому что «выполняется один раз при маунте» никогда не было гарантией, которую могла дать модель программирования, и команде React нужно было, чтобы код перестал на неё полагаться. Переиспользуемое состояние — сохранение состояния компонента при его удалении и повторном добавлении, например мгновенное восстановление экрана при навигации назад — требует, чтобы React мог размонтировать и заново смонтировать компоненты с повторным запуском эффектов. Любой эффект, ломающийся на цикле маунт→cleanup→маунт, сломался бы и на этих фичах, и ломается уже сегодня на перезапусках из-за deps. Двойной маунт — дешёвый дев-тайм фаззинг инварианта, которого модель требовала всегда: эффект обязан быть безопасным при любом числе запусков, пока каждый запуск спарен со своим cleanup. Лечение — никогда не считать вызовы (хак с ref didInit); лечение — сделать установку и снос симметричными, чтобы счёт не имел значения.
Гонки fetch: баг, который каждый дашборд отгружает по разу
Загрузка данных в эффекте — это синхронизация с удалённой системой, а удалённая система отвечает не по порядку. Прежде чем написать fetch внутри useEffect, спроси себя: что произойдёт, если медленный ответ придёт после быстрого? Баг из Hook в коде: эффект для "invoice" стартует 900-мс запрос; deps меняются; эффект для "invoices overdue" стартует 200-мс запрос и резолвится, отрисовывая свежие результаты; затем приземляется устаревший 900-мс ответ и вызывает setResults со старыми данными. Побеждает последняя запись, а последним писал устаревший запрос. Ничего не падает, в логах пусто — просто неверные данные на экране с вероятностью, пропорциональной джиттеру сети; поэтому в проде оно всплывает, а в офисе «не воспроизводится».
Cleanup-функция — точка отмены. Два стандартных паттерна, часто вместе: ignore-флаг на замыкание и AbortController (встроенный браузерный API для отмены fetch-запросов):
useEffect(() => {
const controller = new AbortController();
let ignore = false; // ремень и подтяжки: страхует неотменяемые async-шаги
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data) => {
if (!ignore) setResults(data); // устаревшее замыкание проверяет СВОЙ флаг
})
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => {
ignore = true; // флаг этого рендера — глушатся ответы только этого эффекта
controller.abort(); // реально отменяет сетевой запрос
};
}, [query]);Флаг ignore работает потому, что у каждого замыкания эффекта свой флаг: cleanup эффекта «invoice» поднимает флаг «invoice», и только ответ «invoice» его проверяет. AbortController идёт дальше — он отменяет запрос на сетевом уровне, освобождая соединение и позволяя серверу прекратить работу; отклонённый промис обязан проглатывать именно AbortError, иначе каждое нажатие клавиши логирует фантомную ошибку. Честный продакшен-ответ обычно «не пишите это руками»: query-библиотеки и лоадеры роутеров реализуют отмену, дедупликацию и кэширование поверх ровно этого механизма. Но сырую версию вы обязаны уметь написать, потому что семантика каждой обёртки — и баги каждой обёртки — сводятся к этому эффекту.
В разработке со StrictMode ваш чат-компонент открывает два WebSocket-соединения при маунте. В проде — одно. Какое прочтение верно?
Диагностируйте и исправьте гонку устаревшего fetch в useEffect
1/3- 01Объясните, как сравнивается массив зависимостей и два классических способа его сломать.
- 02Зачем StrictMode монтирует компоненты дважды в разработке и что означает провал под ним?
useEffect — не lifecycle-хук с фазами маунта и обновления; это контракт синхронизации компонента с внешней системой — сетью, сокетами, таймерами, DOM-слушателями, сторонними SDK. Тело эффекта начинает синхронизацию, возвращаемая функция её останавливает, и React выполняет эту пару столько раз, сколько требуют данные. Эффекты выполняются после коммита и отрисовки, и у каждого рендера своё замыкание эффекта, замороженное над props и state этого рендера — нет одного долгоживущего эффекта, наблюдающего смену значений, есть последовательность короткоживущих. Массив deps декларирует, какие реактивные значения читает эффект; React сравнивает его поэлементно через Object.is, поэтому инлайновый объект или функция — гарантированный перезапуск на каждом рендере, а пропуск значения, которое эффект реально читает, — устаревшее замыкание: правило exhaustive-deps — проверка корректности, а не стиль. Cleanup рендера N выполняется перед эффектом рендера N+1 и ещё раз при размонтировании, намеренно замыкаясь над старыми значениями, потому что сносится именно старая подписка или старый запрос. Дев-режимный двойной маунт StrictMode (маунт, cleanup, маунт) — зонд этой симметрии: если два маунта удваивают соединения, cleanup отсутствует или асимметричен, и та же утечка стреляет в проде при каждой смене deps или ремоунте — выключив StrictMode, вы оставляете баг и теряете детектор. Загрузка данных наследует всё это плюс переупорядочивание сети: медленный устаревший ответ может приземлиться после быстрого свежего и победить последней записью, поэтому cleanup обязан отменять — ignore-флаг на замыкание эффекта глушит устаревшие ответы, AbortController отменяет запрос на сетевом уровне (глотайте AbortError в catch), а продакшен-код обычно делегирует это query-библиотеке, построенной ровно на этих примитивах. Теперь, когда встречаешь эффект с fetch и без cleanup — ты знаешь, какую гонку он поставляет в прод.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.