useEffectEvent: код, который не должен перезапускать эффект
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Статья про зависимости оставила третий случай без ответа: эффект должен видеть значение, но перезапускаться из-за него не должен. Подписка на канал читает флаг «без звука» — и честный список зависимостей переподключает канал при каждом нажатии кнопки. useEffectEvent вырезает из эффекта кусок, который не является причиной перезапуска, и отдаёт ему всегда свежий снимок. Заодно станет ясно, почему useCallback с этой работой не справляется.
Модель та же, что в статье про зависимости: канал сообщений — внешний мир, эффект подписывается на комнату и отписывается при уборке. Настоящего сокета в песочнице нет, поэтому канал имитируется: subscribe раз в сто миллисекунд присылает сообщение и после четвёртого замолкает, чтобы вывод не рос бесконечно. У компонента два состояния — комната и флаг «без звука». Обработчик сообщения читает muted, значит, по её правилу muted перечислен. Кнопка «без звука» нажимается на 250-й миллисекунде. Сколько раз напечатается «подписка на»?
Два раза, хотя комната не менялась. Список честный, и React сделал ровно то, что список описывает: muted стал другим, старый эффект смотрит в прошлое — уборка, новый запуск. Видна и цена: нумерация началась заново, третье и четвёртое сообщения первого соединения так и не пришли. Будь на месте имитации настоящий сокет, каждый клик по кнопке звука рвал бы соединение с сервером.
В коде части II этого не было: обработчик сообщений был один, читал state.muted из живого объекта и всегда видел текущее значение. React заменил живой объект снимками, и теперь свежий muted есть только в свежем эффекте — а свежий эффект означает новую подписку.
Оба лечения из статьи про зависимости здесь не подходят. Функция-обновитель ни при чём: muted нужно не изменить, а прочитать, чтобы решить, как обработать сообщение. Остаётся второе — убрать muted из списка и подписываться только по комнате. Линтер возразит, но посмотрим, что будет.
Одна подписка — и звук после выключения. Обработчик родился в первой отрисовке с muted = false, а нового эффекта не будет, пока не сменится комната. Это то самое устаревшее замыкание, только теперь мы устроили его нарочно.
Сам эффект тут на месте: подписка — синхронизация с внешним миром, и эффект отвечает за неё по праву. Не на месте только чтение muted внутри него.
Тело эффекта состоит из двух разных частей. Подписка на комнату — реактивная: сменилась комната — нужно переподключиться, поэтому room живёт в списке. Реакция на сообщение — нет: сменился muted — переподключаться незачем, достаточно, чтобы следующее сообщение обработали с новым флагом. useEffectEvent позволяет вынести вторую часть из эффекта.
Одна подписка, и с третьего сообщения — «тихо». Хук принимает функцию и возвращает её обёртку. Обёртка при каждом коммите начинает вызывать функцию из последней отрисовки: React хранит на каждую такую функцию ячейку с полем impl и в коммите подменяет в ней реализацию. Поэтому обработчик, созданный эффектом первой отрисовки, через onMessage дотягивается до muted той отрисовки, которая на экране сейчас.
Это и есть возвращение к живому state.muted из части II — но в одном месте и под присмотром React. Функция-событие видит не глобальную переменную, которую кто угодно меняет когда угодно, а снимок последней закоммиченной отрисовки. Изменение, которое ещё не отрисовано, она не увидит.
Такую функцию называют событием эффекта: она похожа на обработчик события — выполняется в ответ на «что-то произошло» и видит актуальные значения, — но запускает её не пользователь, а эффект. Отсюда имя хука.
Проверим, что она видит, на застрявшем счётчике из статьи про зависимости. Там было два лечения: обновитель и перезапуск по [count]. Третье — тик как событие. Пустой массив остаётся честным: эффект больше не читает count, его читает событие. Что напечатают пять тиков?
Ноль, один, два, три, четыре — и пятёрка на экране. Каждый тик прочитал count той отрисовки, которая к его моменту была закоммичена: перерисовка после setCount успевает за миллисекунды, а следующий тик приходит через сто. В отличие от обновителя, событие ещё и видит число: может напечатать его, отправить, сравнить с порогом.
useEffectEvent появился в React 19.2 осенью 2025 года. До этого ту же идею собирали руками из useRef — объекта с полем current, который один и тот же во всех отрисовках; подробно о нём следующая статья. В коробку кладут свежее значение, а обработчик читает из коробки, а не из замыкания.
Работает так же, но с оговорками. Коробка обновляется в эффекте, то есть уже после коммита, и между ними у обработчика короткое окно со старым значением. Читая mutedRef.current, вы обходите список зависимостей, а линтер об этом не узнает. useEffectEvent делает то же самое штатно: подмена происходит в коммите, а линтер знает, что перед ним нереактивная функция.
У React есть другой хук, возвращающий функцию, — useCallback. Ему посвящена глава про скорость, но азы понадобятся здесь: useCallback(fn, deps) возвращает ту же самую функцию, пока значения в deps не изменились, и новую — когда изменились. Кажется, это то, что нужно: обернём обработчик, положим его в список зависимостей эффекта, и ссылка перестанет прыгать. Сколько будет подписок?
Снова две. useCallback не убирает muted из зависимостей, а переносит: обработчик зависит от muted, эффект — от обработчика. Стоило флагу измениться — новая функция, новая ссылка, Object.is отвечает «не та», эффект перезапускается. Ровно так и задумано: useCallback — реактивная вещь. Он удерживает ссылку, только пока ничего не изменилось, и нужен, чтобы дочерний компонент или эффект не срабатывали зря при неизменных данных.
Соблазн написать useCallback(fn, []) — та же ложь, что пустой список у эффекта: функция навсегда запомнит первый muted, и линтер потребует перечислить всё, что она читает. Два хука решают разные задачи.
useCallback | useEffectEvent | |
|---|---|---|
| Реактивность | да: новая функция, когда меняются её зависимости | нет: обёртка не зависит ни от чего |
| Какой снимок видит | той отрисовки, в которой создана последняя версия | последней закоммиченной отрисовки |
| В списке зависимостей эффекта | обязательно | нельзя |
| Передать дочернему компоненту | да, ради этого и существует | нет |
| Вызвать во время рендера | можно | ошибка |
| Задача | удержать ссылку, чтобы memo и эффекты не срабатывали зря | вынести из эффекта часть, которая не должна его перезапускать |
У функции-события узкая область применения, и React с линтером её охраняют. Первый запрет — не перечислять в зависимостях. Правило exhaustive-deps знает про useEffectEvent: оно не потребует добавить функцию-событие в список, а если добавите — попросит убрать. В песочнице линтера нет, так что можно ослушаться и посмотреть. Добавлена кнопка «просто клик», которая меняет состояние, не связанное с подпиской. Сколько подписок?
Три — по одной на каждую отрисовку, включая две от кнопки, которая к каналу отношения не имеет. React не обещает, что обёртка будет одной и той же от отрисовки к отрисовке, и в нынешней реализации она каждый раз новая. Перечислить её — значит перезапускать эффект после каждой отрисовки, то есть получить худший вариант из всех.
Второй запрет — не передавать функцию-событие дочерним компонентам и своим хукам. Получатель не знает, что перед ним нереактивная функция: положит в свои зависимости — получит перезапуск на каждую отрисовку, как выше; вызовет в рендере — получит ошибку. Функция-событие принадлежит эффектам того компонента, где объявлена.
Третий запрет — вызывать во время рендера. Обёртка проверяет, не идёт ли сейчас рендер, и бросает исключение. Значение из последней отрисовки в теле компонента и не нужно: там есть сами count и muted, свежее некуда.
Компонент ниже читает состояние через событие прямо при рендере. React бросает исключение с текстом «A function wrapped in useEffectEvent can't be called during rendering» — он есть и в отладочной сборке песочницы. До панели вывода этот текст всё равно не доедет: бросил его скрипт с чужого происхождения, и браузер отдаёт песочнице только «Script error.». Поэтому первая строка говорит, что текст скрыт, а вторую печатает сам React — о том, где упало.
Ошибка при рендере без границы ошибок роняет всё дерево: кадр остаётся пустым, а исключение уходит в консоль. Границы ошибок — в главе про свои хуки и ожидание.
Раз функция-событие видит всё свежим и ничего не перезапускает, соблазн очевиден: завернуть в неё эффект целиком и оставить пустой список. Линтер промолчит — он честно считает функцию-событие нереактивной. Кнопка на 250-й миллисекунде переводит в комнату «помощь». Откуда придут сообщения после этого?
Из «общего». На экране «помощь», а подписка так и осталась первой: эффект с пустым списком не перезапускается, а событие лишь читает свежий room — но его никто не вызывает второй раз. Смена комнаты — причина заново синхронизироваться с внешним миром, а причины должны жить в эффекте и в его списке.
Отсюда правило границы. Про каждое значение, которое читает эффект, спросите: если оно изменится, нужно ли заново синхронизироваться — переподключиться, перезапросить, пересоздать? Да — значение реактивное, оно остаётся в эффекте и в списке. Нет, нужно лишь, чтобы в момент события взяли свежее значение, — оно уходит в useEffectEvent. В подписке на комнату room отвечает «да», muted — «нет».
Реактивное значение, которое нужно и событию, лучше передавать аргументом, а не читать из замыкания. Компонент ниже пишет в журнал посещение адреса вместе с числом товаров в корзине; корзина должна меняться без записи. Сколько строк попадёт в журнал?
Две — по одной на адрес. Добавления в корзину журнал не трогают, но число в строке свежее. Аргумент делает явным, какой адрес записывается: тот, на который отреагировал эффект. Читать url изнутри события тоже можно, и здесь результат совпадёт, но связь «эффект сработал из-за этого адреса — записываем этот адрес» из кода пропадёт.
Сигнатура хука — useEffectEvent<T extends Function>(callback: T): T: наружу выходит тот же тип функции, что и внутрь. Поэтому ошибка в аргументах ловится обычной диагностикой, как в статье про пропсы.
Компилятор недоволен строкой с onVisit(42): аргумент типа number нельзя назначить параметру типа string. Код при этом выполняется и печатает 42 — типы стёрты, вызов состоялся.
И последнее. Если, расставляя границу, вы обнаруживаете, что в событие уходит всё тело эффекта, а в списке не остаётся ничего, — скорее всего, перед вами не эффект, а обработчик из статьи «Когда эффект не нужен». useEffectEvent нужен эффектам, у которых есть настоящая причина перезапуска, и только для той их части, которая причиной не является.
useEffectEvent — не способ заткнуть линтер. Это способ сказать React правду: вот причина эффекта, а вот реакция, которой причина не нужна.
- Третий случай из статьи про зависимости: значение нужно видеть, но перезапускать эффект из-за него нельзя — это работа для useEffectEvent.
- Функция-событие не реактивна: она не перечисляется в зависимостях и при каждом коммите начинает видеть значения последней отрисовки.
- Вызывать её можно только из эффекта — не во время рендера, не из дочерних компонентов и не через пропсы.
- useCallback решает другую задачу: он реактивен, меняется вместе с зависимостями и лишь удерживает ссылку, пока ничего не изменилось.
- Граница проходит по вопросу «если это изменится, нужно ли заново синхронизироваться?»: да — в эффект и список, нет — в useEffectEvent.
- Если в событие просится всё тело эффекта, а список пустеет, — это не эффект, а обработчик из статьи «Когда эффект не нужен».
1/4Эффект подписывается на комнату room, обработчик сообщения читает флаг muted, список зависимостей — [room, muted]. Пользователь нажал «без звука». Что произойдёт с подпиской?
2/4tick обёрнут в useEffectEvent и делает setCount(count + 1). Эффект с пустым списком заводит интервал, который вызывает tick() каждые 100 мс. Что окажется на экране после пяти тиков?
3/4Обработчик обёрнут в useCallback(fn, [muted]) и перечислен в зависимостях эффекта: [room, onMessage]. Перезапустится ли эффект при смене muted?
4/4Функцию-событие из useEffectEvent перечислили в зависимостях эффекта: [room, onMessage]. Что изменится?
Горячая клавиша с шагом
Компонент вешает на документ слушатель клавиши «+», которая прибавляет к счёту текущий шаг. Слушатель — настоящий addEventListener из части II, поэтому он должен видеть свежий step. Сейчас это достигается перезапуском: при каждой смене шага слушатель снимается и вешается заново. Сделайте так, чтобы «слушатель повешен» напечаталось один раз, а «+» по-прежнему прибавлял актуальный шаг: на экране должно получиться 11.
Подписка на клавиатуру — реактивная часть, но ни от чего не зависит: слушатель один на всю жизнь компонента. Реакция на клавишу читает step и count — это событие. Выносим её в useEffectEvent, а список оставляем пустым, и он честный: эффект читает только onPlus, а функцию-событие перечислять нельзя.
Одна строка «слушатель повешен» и 11 на экране: первое нажатие прибавило единицу, второе — десятку, потому что событие увидело step той отрисовки, которая была на экране в момент нажатия. Обновитель c => c + step здесь уже не нужен: событие видит свежий count само.
Автосохранение, которое не успевает
Черновик сохраняется раз в 300 мс — точнее, должен. Эффект перечисляет text, и каждая набранная буква пересоздаёт таймер, так что во время набора не сохраняется ничего. Запустите и посчитайте, сколько раз напечаталось «автосохранение запущено» и сколько — «сохранено». Потом почините: таймер должен завестись один раз, а каждое сохранение — видеть текущий текст. Подсказка: обновитель не поможет, текст нужно прочитать, а не изменить.
Шесть запусков и одно сохранение — уже после того, как набор закончился. Таймер — реактивная часть без зависимостей, а сохранение читает text, но не должно перезапускать таймер. Это событие.
Один запуск и три сохранения до снятия компонента, и каждое печатает столько букв, сколько было на экране в его момент. Буквы, набранные между сохранениями, ничего не пересоздают.
Посчитать до запуска
Обработчик обёрнут в useCallback с зависимостью [muted] и перечислен в эффекте. Клики идут так: «просто клик» на 150-й миллисекунде, «без звука» — на 250-й. Не запуская, запишите, сколько раз напечатается «подписка на» и с какими префиксами придут сообщения. Потом проверьте.
Две подписки. «Просто клик» перерисовал компонент, но muted не менялся — useCallback вернул ту же функцию, зависимости эффекта совпали, подписка уцелела. «Без звука» изменил muted — новая функция, новая ссылка, уборка и вторая подписка. Первое сообщение приходит со «звук:», все сообщения второй подписки — с «тихо:», и их нумерация начинается заново.
Так useCallback и работает: он экономит перезапуски при неизменных данных, но не отменяет их при изменившихся. Чтобы подписка пережила смену muted, обработчик нужно оборачивать в useEffectEvent и убирать из списка.