React

Жизненный цикл эффекта и useEffectEvent

Чат-комната подключается к серверу и при каждом новом сообщении показывает всплывающее уведомление с выбранной темой - тёмной или светлой. Соединение должно перезапускаться при смене комнаты, но не при смене темы. Если положить тему в зависимости эффекта, каждое переключение оформления будет рвать и поднимать сокет заново. Если не положить - уведомление застрянет на старой теме. Эта точная боль и привела к появлению useEffectEvent в React 19.2.

  • Чаты и мессенджеры: соединение зависит от комнаты, но уведомление читает свежие настройки пользователя без переподключения
  • Аналитика навигации: эффект логирует посещение URL и подмешивает текущий план подписки, который не должен перезапускать лог
  • Плееры и стримы: подписка на медиа-поток зависит от id трека, а громкость и тема - нереактивный контекст внутри обработчика
  • Real-time дашборды: переподписка только при смене источника данных, а формат вывода берётся свежим на момент события
  • Игры в браузере: игровой цикл (requestAnimationFrame) не должен перезапускаться из-за смены настроек звука

Предварительные знания

  • useEffect: синхронизация с внешней системой, массив зависимостей и функция очистки
  • Замыкания в JavaScript: функция запоминает переменные из области, где была создана
  • Понимание того, что каждый рендер создаёт свой набор значений props и state

Почему появился useEffectEvent

Долгие годы разработчики боролись с дилеммой: линтер требует включить все читаемые значения в массив зависимостей, но часть значений не должна перезапускать эффект. Обходные пути были некрасивыми - ref-хаки, отключение правила линтера комментарием, искусственное разбиение эффектов. Команда React несколько лет проектировала Effect Events под рабочим названием useEvent, обсуждала их в RFC и экспериментальных сборках. Хук стабилизировался как useEffectEvent в React 19.2 (октябрь 2025) и закрыл разрыв между 'реактивным' и 'нереактивным' кодом внутри эффекта.

Эффект - это синхронизация, а не mount/unmount

Привычка из эпохи классов - думать про эффект как про componentDidMount и componentWillUnmount: 'настроить при появлении, убрать при исчезновении'. Эта модель ломается, как только у эффекта есть зависимости. Правильнее: у эффекта есть только два момента - начать синхронизацию и остановить её. Компонент может оставаться на экране, но эффект за его жизнь начнётся и остановится много раз, по разу на каждое изменение зависимостей.

Из этой модели вытекает важное правило: тело эффекта и его cleanup должны быть зеркальны. Если тело подключается к комнате 'general', cleanup должен отключаться именно от 'general', а не от текущего значения. React гарантирует это автоматически, потому что cleanup замкнут на тот же рендер, что и тело: они видят одно и то же значение roomId.

Хороший тест на корректность эффекта: представить, что зависимость меняется десять раз подряд. Если после серии 'стоп - старт' внешняя система осталась в одном чистом соединении, а не в десяти повисших, значит cleanup написан верно и зеркален телу.

Почему вредно думать про эффект как про 'настроить на mount, убрать на unmount'?

Реактивные и нереактивные значения

Реактивное значение - это любое значение, вычисленное во время рендера, которое может отличаться между рендерами: props, state и всё, что из них выведено. Эффект читает реактивное значение - значит оно влияет на синхронизацию, и линтер справедливо требует включить его в зависимости. Нереактивные значения - те, что не пересчитываются при рендере и стабильны: импортированные константы, функции из модуля. Они в зависимости не попадают.

ЗначениеРеактивноеВ зависимостях
props.roomIdДа, может меняться между рендерамиДолжно быть
useState countДа, меняется через сеттерДолжно быть
const url = '/api/' + roomIdДа, выведено из реактивного roomIdДолжно быть
импортированная константа MAXНет, стабильна между рендерамиНе нужно

Сложность начинается, когда эффект читает реактивное значение, но логически не должен из-за него перезапускаться. Классика: соединение зависит от roomId, но внутри обработчика входящего сообщения нужно показать уведомление с текущей темой. Тема реактивна, и линтер требует её в зависимостях. Но если её добавить, переключение темы будет рвать соединение. Если убрать вручную - уведомление застрянет на старой теме при следующем сообщении. Это и есть разрыв, который раньше не имел чистого решения.

Молча отключать правило react-hooks/exhaustive-deps комментарием - плохая идея. Линтер прав: theme действительно читается в эффекте. Подавление предупреждения не решает проблему, а прячет её: при смене темы уведомление продолжит показывать старую тему. Нужен инструмент, который скажет React 'это значение читается, но не реактивно для синхронизации'.

Какое из значений НЕ является реактивным с точки зрения эффекта?

useEffectEvent: свежие значения без переподписки

useEffectEvent (React 19.2) выделяет нереактивную часть логики эффекта в отдельную функцию - Effect Event. Внутри неё код всегда читает свежие props и state на момент вызова, но сама функция не считается реактивной: её не указывают в массиве зависимостей, и она не вызывает перезапуск эффекта. Это ровно тот инструмент, которого не хватало для разрыва между 'значение читается' и 'значение не должно рвать синхронизацию'.

Теперь смена темы не трогает соединение: эффект зависит только от roomId. При этом onMessage при каждом вызове видит актуальную theme, потому что Effect Event всегда читает значения последнего рендера. Линтер тоже спокоен: Effect Events намеренно исключены из правила exhaustive-deps, их не нужно перечислять в зависимостях.

  • Effect Event объявляют только внутри компонента или хука, рядом с эффектом, который его использует
  • Его нельзя передавать наружу, в другой компонент или в зависимости другого хука - он привязан к своему эффекту
  • Вызывать Effect Event можно только из эффекта, а не во время рендера
  • Он не заменяет обработчики событий пользователя: для onClick по-прежнему обычная функция

Простое правило выбора. Если значение должно перезапускать синхронизацию при изменении - это обычная зависимость в массиве. Если значение лишь читается в момент срабатывания и не должно ничего перезапускать - его место внутри useEffectEvent. Реактивное идёт в deps, нереактивное по смыслу - в Effect Event.

Чем useEffectEvent отличается от обычной функции, объявленной в теле компонента и вызванной из эффекта?

Связь с другими темами

Урок углубляет модель эффекта. Дальше:

  • useEffect — База: без понимания зависимостей и cleanup жизненный цикл эффекта не складывается
  • Свои хуки — Effect Event обычно объявляют внутри собственного хука рядом с эффектом, который его использует
  • Модель рендера — Реактивность значения - прямое следствие того, что замыкание эффекта принадлежит конкретному рендеру

Итог

  • Жизненный цикл эффекта стоит мыслить как пару 'начать синхронизацию' и 'остановить', а не как mount и unmount компонента
  • При изменении реактивной зависимости React сначала останавливает старую синхронизацию (cleanup), затем запускает новую - это один цикл, а не отдельные события
  • Реактивные значения - это props, state и всё выведенное из них; они обязаны быть в массиве зависимостей
  • Нереактивные значения не меняются между рендерами по сути логики и не должны перезапускать эффект
  • useEffectEvent (React 19.2) выделяет нереактивную часть эффекта: она читает всегда свежие значения, но не входит в зависимости и не вызывает переподписку

Связанные уроки

  • rc-11-useeffect — Жизненный цикл эффекта имеет смысл только после того, как разобраны синхронизация, зависимости и cleanup
  • rc-16-custom-hooks — useEffectEvent почти всегда живёт рядом с эффектом внутри собственного хука, обёртывая логику подписки
  • rc-10-render-mental-model — Реактивность значений вытекает из того, что замыкание эффекта захватывает props и state конкретного рендера
Жизненный цикл эффекта и useEffectEvent

0

1

Войти