Уборка, двойной запуск и гонки запросов

эффекты≈ 14 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

Уборка из статьи о синхронизации выглядела частностью: у слушателя есть removeEventListener, у интервала — clearInterval. Правило шире, и его удобно увидеть на отложенной задаче. Поиск по мере ввода не должен уходить на сервер с каждой буквой — только через 300 мс после последней. Эффект синхронизирован с query: «для этого запроса должен быть заведён отложенный поиск». Кнопка добавляет по букве, три клика идут с шагом 50 мс. Сколько строк «ищем» напечатается и с какими запросами?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Правило: всё, что эффект начал, уборка прекращает. Для таймера это clearTimeout с тем идентификатором, который вернул setTimeout этого же запуска.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна строка — «реа», через 300 мс после последнего клика. Два первых таймера остановлены уборкой раньше, чем успели сработать: React вызывает её перед каждым новым запуском тела, и она снимает ровно свой таймер. Обратите внимание, где живёт id: уборка — замыкание над переменными того же запуска, что и тело, поэтому она объявлена внутри эффекта, а не рядом с ним. Ранний return при пустом запросе возвращает undefined, и типы это разрешают: EffectCallback обещает вернуть либо ничего, либо функцию.

Что начал эффектЧто делает уборка
setTimeout, setIntervalclearTimeout, clearInterval с тем же идентификатором
addEventListenerremoveEventListener с той же функцией
Подписка: сокет, store.subscribe, MutationObserverОтписка: close(), функция, которую вернул subscribe, disconnect()
Сторонний виджет: карта, редакторdestroy() виджета
Класс или атрибут на элементе вне ReactВернуть, как было
Запрос к серверуПометить ответ ненужным или прервать через AbortController — разделы ниже

Уборка симметрична телу: каждой строке «начать» — своя строка «прекратить». Если у чего-то пары нет — это либо утечка, либо гонка.

Пропущенную уборку трудно заметить: код работает, экран правильный, а лишний таймер или слушатель проявится когда-нибудь потом — при переходе между экранами или при повторном открытии панели. Поэтому в режиме разработки <React.StrictMode> устраивает эффектам проверку: после первого коммита React выполняет тело эффекта, тут же его уборку и снова тело. Состояние при этом сохраняется, DOM не пересоздаётся — прогоняется лишний раз только пара «начать — прекратить». Если после неё внешний мир в том же состоянии, что после одного запуска, уборка полная. Если нет — эффект что-то начал и не прекратил.

Песочница учебника собирает React отладочной сборкой, так что StrictMode в ней работает: оберните пример в <React.StrictMode> — и пара «начать — прекратить» прогонится лишний раз. Примеры ниже обходятся без обёртки: тот же путь виден и так, если снять компонент с экрана и поставить обратно — React вызовет уборку и тело заново. Ниже чат «подключается» к комнате; подключение изображает счётчик открытых соединений на уровне модуля. Сколько соединений останется открытыми, когда компонент вернётся на экран?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два — при одном компоненте на экране. Первое подключение пережило уход компонента, второе добавилось к нему. Ровно это увидел бы разработчик в StrictMode сразу после первого коммита, не дожидаясь, пока пользователь походит по экранам: две строки «подключились» и счётчик, равный двум. Не потому, что StrictMode «монтирует дважды», а потому, что уборки нет, — и React показал это первым.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Подключились, отключились, подключились — и одно соединение. Двойной запуск для такого эффекта безвреден: пара «начать — прекратить» отработала лишний раз и следов не оставила. В этом и смысл проверки: эффект, написанный как синхронизация, переживает любое число перезапусков, а эффект, написанный как «один раз при появлении», выдаёт себя на первом же.

У двойного запуска два следствия. Первое: если эффект ломается от двойного запуска, сломан эффект, а не StrictMode, — отключать режим или ставить флаг «уже запускался» значит прятать утечку, которая всё равно проявится у пользователя. Второе: перезапуск случается и без StrictMode. Fast Refresh при сохранении файла выполняет уборку и тело заново, а панель, которую пользователь закрыл и открыл, проходит тот же путь. Next.js включает StrictMode в разработке по умолчанию — и правильно делает.

Эффект без уборкиЧто покажет двойной запуск
addEventListener без снятияДва слушателя: каждое событие обрабатывается дважды
setInterval без остановкиДва интервала: счётчик тикает вдвое быстрее
Запрос без пометки «ответ не нужен»Два запроса; на экране тот ответ, что пришёл последним, — гонка, о ней ниже
POST «оплатить» при появленииДвойная оплата. Это не эффект, а обработчик — статья «Когда эффект не нужен»

StrictMode повторяет и вызов тела компонента — проверка чистоты рендера из статьи «Триггер, рендер, коммит». Эффекты он проверяет тем же способом: если результат зависит от числа запусков, что-то не так.

Популярный обходной путь — завести useRef(false), в эффекте проверять его и выходить, если запуск второй. Эффект и правда выполнится один раз. Но флаг не чинит ничего: слушатель по-прежнему висит после ухода компонента, запрос по-прежнему приходит невпопад — просто в разработке это больше не видно. Хуже того, при настоящем повторном показе того же компонента флаг уже true, и подписки не будет вовсе. Уборка решает обе проблемы, флаг — ни одной.

Запрос при показе — законная работа для эффекта: «пока на экране этот запрос, для него должны быть загружены данные». Сети в песочнице нет, поэтому сервер изображает fakeFetch — промис с задержкой. Первый черновик пишется сам собой: раз внутри нужен await, сделаем колбэк эффекта асинхронным. Компилятор ответит ещё до запуска — предскажите, что именно.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Аргумент типа "() => Promise<void>" нельзя назначить параметру типа "EffectCallback"» — и вложенная причина: Promise<void> не годится вместо void | Destructor. Тип говорит всё. React ждёт от колбэка либо ничего, либо функцию уборки, а async-функция всегда возвращает промис, и деть его React некуда. Код при этом выполняется — типы стираются — и даже работает: React молча выбрасывает промис. Но эффект без права на уборку нам как раз и не подходит. Асинхронность переезжает внутрь: then или вложенная async-функция, а сам колбэк остаётся синхронным.

Теперь гонка. Задержка у fakeFetch зависит от запроса: короткий «ре» сервер ищет дольше — совпадений больше, так бывает и в жизни. Компонент стартует с «ре», через 50 мс пользователь дописывает до «реакт». Что окажется на экране через полсекунды?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На экране результаты для «ре» — при запросе «реакт». Ответ на «реакт» пришёл первым и был показан, а через 150 мс пришёл ответ на «ре» и его перекрыл. Эффект первого запуска отправил запрос и ничего не сделал, чтобы его ответ не приняли: колбэк в then — замыкание с query = "ре" и живым setItems, и о том, что он устарел, ему никто не сказал. Порядок ответов сеть не гарантирует, и чем медленнее сервер, тем чаще выигрывает старый.

Это та же беда, что с таймерами из первого раздела: начатое пережило состояние, для которого его начали. В части II такой гонки не было по другой причине — у доски был один state и один запрос за раз. Здесь пользователь меняет запрос быстрее, чем сервер отвечает, и на экран претендуют несколько ответов.

Гонка не воспроизводится на быстром сервере в локальной сети — и стреляет у пользователя с плохим соединением. Проверять её нужно с искусственной задержкой, как здесь.

Промис не остановить: у него нет метода «отменить», и ответ придёт в любом случае. Но уборке и не нужно останавливать сервер — достаточно сделать так, чтобы ответ, когда он придёт, никуда не попал. Переменная запуска ignore: тело создаёт её со значением false, уборка переводит в true, колбэк проверяет её перед сеттером.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь на экране «реакт», а опоздавший ответ отмечен в консоли и отброшен. Механика — замыкание из части I: ignore объявлена внутри запуска, и уборка с колбэком замкнули одну и ту же переменную. У каждого запуска эффекта — своя ignore; уборка запуска «ре» переключила только её, запуск «реакт» продолжил жить со своей false.

Тот же флаг делает безвредным двойной запуск из второго раздела: в разработке уйдут два запроса, ответ на первый проигнорируется, на второй — попадёт на экран. Один лишний запрос в разработке — приемлемая цена за проверку, которая ловит гонку до того, как её увидит пользователь.

Флаг не экономит сеть: запрос до сервера всё равно доходит, сервер всё равно отвечает. Настоящий fetch умеет прерываться: он принимает signal от AbortController, и controller.abort() отклоняет промис ошибкой с именем AbortError, а браузер закрывает соединение. Научим этому fakeFetch — так же, как ведёт себя настоящий, — и уборка станет одной строкой.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Запрос на «ре» прерван в момент клика, и ждать его 300 мс никому не пришлось. Прерванный запрос отсеивается в catch до того, как дойдёт до обработки ошибок: AbortError — не сбой, а ожидаемый исход уборки. Всё остальное, что попадёт в catch, — настоящие ошибки, и им место в состоянии, к которому мы сейчас перейдём.

СпособЧто происходит с запросомКогда выбирать
Флаг ignoreДоходит до сервера, ответ отбрасываетсяПромис, который нельзя прервать: сторонняя библиотека, свой API без сигнала
AbortControllerСоединение закрывается, промис отклоняется с AbortErrorfetch и всё, что принимает signal

Осталось собрать запрос целиком. У него три исхода — идёт, удался, не удался, — и хранить их тремя булевыми флагами значит допускать кадр «данные есть, и загрузка ещё крутится». Размеченное объединение из части III закрывает эту дыру: состояние либо loading, либо ok с данными, либо error с сообщением, и компилятор не даст прочитать items там, где их нет. Сервер здесь умеет отказывать: на пустой запрос он отвечает ошибкой. Клики: «реакт» через 50 мс, пустой запрос через 250 мс. Что будет на экране через полсекунды и что случится с ответом на «ре», который придёт на 300-й миллисекунде?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На экране «ошибка: пустой запрос», а ответ на «ре» пропущен: к моменту его прихода уборка того запуска давно выставила ignore. Сеттер в начале эффекта — тот редкий случай, когда сеттеру место в эффекте: он ничего не выводит из пропсов и состояния, а сообщает о внешнем мире — запрос ушёл, данных пока нет. Функция describe разбирает объединение по метке, и state.items доступен только в ветке ok; уберите ветку — и компилятор напомнит, что функция не на всех путях возвращает строку.

Двадцать строк на один запрос — и в них ещё нет ни кеша, ни повтора при обрыве, ни склейки одинаковых запросов от двух компонентов. Поэтому в реальном коде запрос в useEffect — не финал. В Next.js данные загружают на сервере, до всякого эффекта: await прямо в компоненте — глава о серверных компонентах. В клиентском коде ожидание выражают через use() и Suspense — глава о своих хуках, — или отдают библиотеке вроде TanStack Query, у которой внутри ровно этот ignore. Механика гонки и уборки от этого не меняется — меняется лишь то, кто её пишет. Когда без библиотеки уже не обойтись, а когда хватает use() с кешем по ключу, разобрано на полке React и Next.

Проверка эффекта с запросом: смените зависимость дважды подряд и подождите. Если экран показывает ответ на первую смену — уборки нет.

  • Всё, что эффект начал, уборка прекращает: таймер, слушатель, подписку, виджет, запрос. Уборка — замыкание над переменными своего запуска, поэтому пишется внутри эффекта.
  • В разработке StrictMode после первого коммита выполняет тело, уборку и снова тело с тем же состоянием. Это проверка уборки: эффект, который от неё ломается, сломан сам, а флаг «уже запускался» лишь прячет утечку.
  • Колбэк useEffect не может быть async: EffectCallback возвращает void или уборку, а не промис. Асинхронность живёт внутри — в then или во вложенной функции.
  • Гонка: ответ на старый запрос приходит после нового и перекрывает его. Промис не отменить, но флаг ignore, выставленный уборкой, делает опоздавший ответ безвредным.
  • AbortController прерывает настоящий fetch: уборка вызывает abort(), промис отклоняется с AbortError — это штатный исход, а не ошибка.
  • Три исхода запроса — размеченное объединение loading | ok | error. В проекте запрос в эффекте уступает место серверным компонентам, use() и библиотекам, но механика уборки в них та же.

1/4Эффект с зависимостями [query] ставит setTimeout на 300 мс и не возвращает уборку. Пользователь меняет query трижды с шагом 50 мс. Сколько раз сработает таймер?

2/4Что делает React с эффектом внутри <React.StrictMode> в режиме разработки после первого коммита?

3/4Эффект отправляет запрос и в then вызывает сеттер. Пользователь сменил query до того, как пришёл ответ, и ответ на старый query пришёл позже нового. Что покажет экран, если уборки нет?

4/4Почему useEffect(async () => { … }, [query]) не проходит проверку типов?

Подсказка с опозданием

Компонент показывает подсказку для текущего слова. Сервер отвечает на «ко» за 300 мс, на «кот» — за 100 мс. Пользователь дописывает букву через 50 мс, и через полсекунды на экране подсказка для «ко». Почините эффект так, чтобы опоздавший ответ не попадал на экран.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Промис для «ко» не остановить, но его результат можно не принять. Флаг запуска ignore: уборка переводит его в true, колбэк проверяет перед сеттером.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Клик через 50 мс перезапускает эффект: уборка запуска «ко» выставляет его ignore, и ответ, который придёт на 300-й миллисекунде, отбрасывается. На экране — «подсказка для «кот»». Если бы fakeHint принимала signal, тот же эффект можно было бы написать через AbortController — и ждать опоздавший ответ не пришлось бы вовсе.

Отложенный запрос

Поиск должен уходить на сервер через 200 мс после последней буквы, а ответ — попадать на экран только если запрос ещё актуален. Сейчас эффект отправляет запрос на каждую букву и принимает любой ответ. Напишите уборку, которая закрывает обе дыры. Клики: «р», «ре», «реа» с шагом 50 мс; запрос на «реа» должен быть единственным в консоли, а его результат — единственным на экране.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Эффект начинает две вещи — таймер и, когда таймер сработает, запрос, — значит, уборка обязана прекратить обе. Таймер останавливает clearTimeout; запрос, если он уже ушёл, обезвреживает флаг ignore.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первые два таймера сняты уборкой до срабатывания, поэтому запрос один — на «реа», — и его результат единственный на экране. Без уборки ответы приходили в обратном порядке: «реа» на 450-й миллисекунде, «ре» на 500-й, «р» на 550-й — и экран доставался самому старому.

Флаг вместо уборки

Коллега защитил эффект от двойного запуска в StrictMode флагом в useRef: подписка ставится один раз, и в разработке лишних строк нет. Пример имитирует проверку — снимает панель и ставит снова — и после этого нажимает клавишу. Предскажите, сколько строк «панель: клавиша» появится, объясните почему и перепишите эффект без флага.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две строки. Флаг живёт в useRef, то есть внутри экземпляра компонента: у новой панели он снова false, и она вешает второго слушателя, а первый так и висит — снять его было некому. В настоящем StrictMode экземпляр тот же, флаг остаётся true, и второй запуск пропускается — но слушатель первого запуска всё равно никто не снимает, и после ухода панели он останется. Флаг спрятал утечку от проверки, а не убрал её.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь строка одна: уход панели снимает её слушателя, новая панель вешает свой. Двойной запуск в разработке для такого эффекта — повесил, снял, повесил — тоже оставит ровно одного слушателя.