Уборка, двойной запуск и гонки запросов
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В статье о синхронизации уборка снимала слушателя и останавливала интервал. Но у неё есть общий договор: всё, что эффект начал, уборка обязана прекратить, — включая то, что «отменить» нельзя, например запрос, ответ на который уже летит. Ровно этот договор React проверяет двойным запуском в разработке, и ровно его нарушение приводит к гонке, когда экран показывает ответ на вопрос, которого никто уже не задаёт.
Уборка из статьи о синхронизации выглядела частностью: у слушателя есть removeEventListener, у интервала — clearInterval. Правило шире, и его удобно увидеть на отложенной задаче. Поиск по мере ввода не должен уходить на сервер с каждой буквой — только через 300 мс после последней. Эффект синхронизирован с query: «для этого запроса должен быть заведён отложенный поиск». Кнопка добавляет по букве, три клика идут с шагом 50 мс. Сколько строк «ищем» напечатается и с какими запросами?
Три — «р», «ре», «реа», — хотя пользователь явно хотел один, последний. Каждый запуск эффекта завёл свой таймер, и никто не остановил предыдущий: React вызвал бы уборку перед следующим запуском, но уборки нет. Таймер пережил состояние, ради которого его завели, и через 300 мс честно отработал для запроса, которого на экране давно нет.
Правило: всё, что эффект начал, уборка прекращает. Для таймера это clearTimeout с тем идентификатором, который вернул setTimeout этого же запуска.
Одна строка — «реа», через 300 мс после последнего клика. Два первых таймера остановлены уборкой раньше, чем успели сработать: React вызывает её перед каждым новым запуском тела, и она снимает ровно свой таймер. Обратите внимание, где живёт id: уборка — замыкание над переменными того же запуска, что и тело, поэтому она объявлена внутри эффекта, а не рядом с ним. Ранний return при пустом запросе возвращает undefined, и типы это разрешают: EffectCallback обещает вернуть либо ничего, либо функцию.
| Что начал эффект | Что делает уборка |
|---|---|
setTimeout, setInterval | clearTimeout, clearInterval с тем же идентификатором |
addEventListener | removeEventListener с той же функцией |
Подписка: сокет, store.subscribe, MutationObserver | Отписка: close(), функция, которую вернул subscribe, disconnect() |
| Сторонний виджет: карта, редактор | destroy() виджета |
| Класс или атрибут на элементе вне React | Вернуть, как было |
| Запрос к серверу | Пометить ответ ненужным или прервать через AbortController — разделы ниже |
Уборка симметрична телу: каждой строке «начать» — своя строка «прекратить». Если у чего-то пары нет — это либо утечка, либо гонка.
Пропущенную уборку трудно заметить: код работает, экран правильный, а лишний таймер или слушатель проявится когда-нибудь потом — при переходе между экранами или при повторном открытии панели. Поэтому в режиме разработки <React.StrictMode> устраивает эффектам проверку: после первого коммита React выполняет тело эффекта, тут же его уборку и снова тело. Состояние при этом сохраняется, DOM не пересоздаётся — прогоняется лишний раз только пара «начать — прекратить». Если после неё внешний мир в том же состоянии, что после одного запуска, уборка полная. Если нет — эффект что-то начал и не прекратил.
Песочница учебника собирает React отладочной сборкой, так что StrictMode в ней работает: оберните пример в <React.StrictMode> — и пара «начать — прекратить» прогонится лишний раз. Примеры ниже обходятся без обёртки: тот же путь виден и так, если снять компонент с экрана и поставить обратно — React вызовет уборку и тело заново. Ниже чат «подключается» к комнате; подключение изображает счётчик открытых соединений на уровне модуля. Сколько соединений останется открытыми, когда компонент вернётся на экран?
Два — при одном компоненте на экране. Первое подключение пережило уход компонента, второе добавилось к нему. Ровно это увидел бы разработчик в StrictMode сразу после первого коммита, не дожидаясь, пока пользователь походит по экранам: две строки «подключились» и счётчик, равный двум. Не потому, что StrictMode «монтирует дважды», а потому, что уборки нет, — и React показал это первым.
У двойного запуска два следствия. Первое: если эффект ломается от двойного запуска, сломан эффект, а не StrictMode, — отключать режим или ставить флаг «уже запускался» значит прятать утечку, которая всё равно проявится у пользователя. Второе: перезапуск случается и без StrictMode. Fast Refresh при сохранении файла выполняет уборку и тело заново, а панель, которую пользователь закрыл и открыл, проходит тот же путь. Next.js включает StrictMode в разработке по умолчанию — и правильно делает.
| Эффект без уборки | Что покажет двойной запуск |
|---|---|
addEventListener без снятия | Два слушателя: каждое событие обрабатывается дважды |
setInterval без остановки | Два интервала: счётчик тикает вдвое быстрее |
| Запрос без пометки «ответ не нужен» | Два запроса; на экране тот ответ, что пришёл последним, — гонка, о ней ниже |
| POST «оплатить» при появлении | Двойная оплата. Это не эффект, а обработчик — статья «Когда эффект не нужен» |
StrictMode повторяет и вызов тела компонента — проверка чистоты рендера из статьи «Триггер, рендер, коммит». Эффекты он проверяет тем же способом: если результат зависит от числа запусков, что-то не так.
Популярный обходной путь — завести useRef(false), в эффекте проверять его и выходить, если запуск второй. Эффект и правда выполнится один раз. Но флаг не чинит ничего: слушатель по-прежнему висит после ухода компонента, запрос по-прежнему приходит невпопад — просто в разработке это больше не видно. Хуже того, при настоящем повторном показе того же компонента флаг уже true, и подписки не будет вовсе. Уборка решает обе проблемы, флаг — ни одной.
Запрос при показе — законная работа для эффекта: «пока на экране этот запрос, для него должны быть загружены данные». Сети в песочнице нет, поэтому сервер изображает fakeFetch — промис с задержкой. Первый черновик пишется сам собой: раз внутри нужен await, сделаем колбэк эффекта асинхронным. Компилятор ответит ещё до запуска — предскажите, что именно.
«Аргумент типа "() => Promise<void>" нельзя назначить параметру типа "EffectCallback"» — и вложенная причина: Promise<void> не годится вместо void | Destructor. Тип говорит всё. React ждёт от колбэка либо ничего, либо функцию уборки, а async-функция всегда возвращает промис, и деть его React некуда. Код при этом выполняется — типы стираются — и даже работает: React молча выбрасывает промис. Но эффект без права на уборку нам как раз и не подходит. Асинхронность переезжает внутрь: then или вложенная async-функция, а сам колбэк остаётся синхронным.
Теперь гонка. Задержка у fakeFetch зависит от запроса: короткий «ре» сервер ищет дольше — совпадений больше, так бывает и в жизни. Компонент стартует с «ре», через 50 мс пользователь дописывает до «реакт». Что окажется на экране через полсекунды?
На экране результаты для «ре» — при запросе «реакт». Ответ на «реакт» пришёл первым и был показан, а через 150 мс пришёл ответ на «ре» и его перекрыл. Эффект первого запуска отправил запрос и ничего не сделал, чтобы его ответ не приняли: колбэк в then — замыкание с query = "ре" и живым setItems, и о том, что он устарел, ему никто не сказал. Порядок ответов сеть не гарантирует, и чем медленнее сервер, тем чаще выигрывает старый.
Это та же беда, что с таймерами из первого раздела: начатое пережило состояние, для которого его начали. В части II такой гонки не было по другой причине — у доски был один state и один запрос за раз. Здесь пользователь меняет запрос быстрее, чем сервер отвечает, и на экран претендуют несколько ответов.
Гонка не воспроизводится на быстром сервере в локальной сети — и стреляет у пользователя с плохим соединением. Проверять её нужно с искусственной задержкой, как здесь.
Промис не остановить: у него нет метода «отменить», и ответ придёт в любом случае. Но уборке и не нужно останавливать сервер — достаточно сделать так, чтобы ответ, когда он придёт, никуда не попал. Переменная запуска ignore: тело создаёт её со значением false, уборка переводит в true, колбэк проверяет её перед сеттером.
Теперь на экране «реакт», а опоздавший ответ отмечен в консоли и отброшен. Механика — замыкание из части I: ignore объявлена внутри запуска, и уборка с колбэком замкнули одну и ту же переменную. У каждого запуска эффекта — своя ignore; уборка запуска «ре» переключила только её, запуск «реакт» продолжил жить со своей false.
Тот же флаг делает безвредным двойной запуск из второго раздела: в разработке уйдут два запроса, ответ на первый проигнорируется, на второй — попадёт на экран. Один лишний запрос в разработке — приемлемая цена за проверку, которая ловит гонку до того, как её увидит пользователь.
Флаг не экономит сеть: запрос до сервера всё равно доходит, сервер всё равно отвечает. Настоящий fetch умеет прерываться: он принимает signal от AbortController, и controller.abort() отклоняет промис ошибкой с именем AbortError, а браузер закрывает соединение. Научим этому fakeFetch — так же, как ведёт себя настоящий, — и уборка станет одной строкой.
Запрос на «ре» прерван в момент клика, и ждать его 300 мс никому не пришлось. Прерванный запрос отсеивается в catch до того, как дойдёт до обработки ошибок: AbortError — не сбой, а ожидаемый исход уборки. Всё остальное, что попадёт в catch, — настоящие ошибки, и им место в состоянии, к которому мы сейчас перейдём.
| Способ | Что происходит с запросом | Когда выбирать |
|---|---|---|
Флаг ignore | Доходит до сервера, ответ отбрасывается | Промис, который нельзя прервать: сторонняя библиотека, свой API без сигнала |
AbortController | Соединение закрывается, промис отклоняется с AbortError | fetch и всё, что принимает signal |
Осталось собрать запрос целиком. У него три исхода — идёт, удался, не удался, — и хранить их тремя булевыми флагами значит допускать кадр «данные есть, и загрузка ещё крутится». Размеченное объединение из части III закрывает эту дыру: состояние либо loading, либо ok с данными, либо error с сообщением, и компилятор не даст прочитать items там, где их нет. Сервер здесь умеет отказывать: на пустой запрос он отвечает ошибкой. Клики: «реакт» через 50 мс, пустой запрос через 250 мс. Что будет на экране через полсекунды и что случится с ответом на «ре», который придёт на 300-й миллисекунде?
На экране «ошибка: пустой запрос», а ответ на «ре» пропущен: к моменту его прихода уборка того запуска давно выставила 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 мс, и через полсекунды на экране подсказка для «ко». Почините эффект так, чтобы опоздавший ответ не попадал на экран.
Промис для «ко» не остановить, но его результат можно не принять. Флаг запуска ignore: уборка переводит его в true, колбэк проверяет перед сеттером.
Клик через 50 мс перезапускает эффект: уборка запуска «ко» выставляет его ignore, и ответ, который придёт на 300-й миллисекунде, отбрасывается. На экране — «подсказка для «кот»». Если бы fakeHint принимала signal, тот же эффект можно было бы написать через AbortController — и ждать опоздавший ответ не пришлось бы вовсе.
Отложенный запрос
Поиск должен уходить на сервер через 200 мс после последней буквы, а ответ — попадать на экран только если запрос ещё актуален. Сейчас эффект отправляет запрос на каждую букву и принимает любой ответ. Напишите уборку, которая закрывает обе дыры. Клики: «р», «ре», «реа» с шагом 50 мс; запрос на «реа» должен быть единственным в консоли, а его результат — единственным на экране.
Эффект начинает две вещи — таймер и, когда таймер сработает, запрос, — значит, уборка обязана прекратить обе. Таймер останавливает clearTimeout; запрос, если он уже ушёл, обезвреживает флаг ignore.
Первые два таймера сняты уборкой до срабатывания, поэтому запрос один — на «реа», — и его результат единственный на экране. Без уборки ответы приходили в обратном порядке: «реа» на 450-й миллисекунде, «ре» на 500-й, «р» на 550-й — и экран доставался самому старому.
Флаг вместо уборки
Коллега защитил эффект от двойного запуска в StrictMode флагом в useRef: подписка ставится один раз, и в разработке лишних строк нет. Пример имитирует проверку — снимает панель и ставит снова — и после этого нажимает клавишу. Предскажите, сколько строк «панель: клавиша» появится, объясните почему и перепишите эффект без флага.
Две строки. Флаг живёт в useRef, то есть внутри экземпляра компонента: у новой панели он снова false, и она вешает второго слушателя, а первый так и висит — снять его было некому. В настоящем StrictMode экземпляр тот же, флаг остаётся true, и второй запуск пропускается — но слушатель первого запуска всё равно никто не снимает, и после ухода панели он останется. Флаг спрятал утечку от проверки, а не убрал её.
Теперь строка одна: уход панели снимает её слушателя, новая панель вешает свой. Двойной запуск в разработке для такого эффекта — повесил, снял, повесил — тоже оставит ровно одного слушателя.