open atlas
↑ К треку
React с нуля до senior RCT · 02 · 01

useEffect — это синхронизация, а не lifecycle

useEffect синхронизирует компонент с внешней системой — это не lifecycle. Deps сравниваются поэлементно через Object.is; cleanup идёт перед каждым перезапуском и при размонтировании. StrictMode монтирует дважды, вскрывая отсутствующий cleanup; гонки fetch лечит AbortController.

RCT Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Страница поиска ушла в прод в четверг. В пятницу пришёл тикет с записью экрана: пользователь печатает «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
Вспомните перед уходом
  1. 01
    Объясните, как сравнивается массив зависимостей и два классических способа его сломать.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.