useEffect — это синхронизация, а не жизненный цикл
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части II после каждого render() вы дописывали ещё пару строк: сохранить доску в localStorage, поменять заголовок вкладки, перезапустить таймер. В React функцию компонента вызываете не вы, а React, — и места «сразу после рендера» в вашем коде больше нет. useEffect возвращает это место, но с другой формулировкой: не «когда компонент появился — сделай», а «пока компонент на экране — снаружи должно быть вот так».
По статье «Триггер, рендер, коммит» тело компонента обязано быть чистым: не трогать DOM, не ставить таймеры, не подписываться на события. Но доска из части II всё это делала — сразу после render(). Куда теперь деть эти строки? React отвечает четвёртым шагом: закончив коммит, он вызывает функции, которые вы передали в React.useEffect. Ниже три console.log — в теле, в эффекте и в обработчике, причём первые два подсматривают в DOM. В каком порядке напечатаются пять строк и что каждая увидит «на экране»?
Строка из тела всегда раньше строки из эффекта, и видят они разное. Тело в первый раз не нашло абзаца вовсе — undefined, его создаст коммит; во второй раз застало старый ноль. Эффект оба раза видит то, что рендер только что вернул: коммит уже прошёл, DOM новый. Обработчик отработал целиком до рендера — это триггер из статьи «Триггер, рендер, коммит». Цепочка целиком: триггер → рендер → коммит → эффекты.
Эффект — не часть рендера, а его последствие. React запоминает переданную функцию, доводит коммит до конца и только потом вызывает её — с теми пропсами и состоянием, для которых она была создана. Поэтому внутри эффекта уместно всё, что запрещено в теле: трогать DOM, ставить таймеры, подписываться, писать в localStorage.
| Где выполняется код | DOM в этот момент | Что здесь уместно |
|---|---|---|
| Обработчик события | Старый: рендера ещё не было | Ответить на действие пользователя, позвать сеттер |
| Тело компонента | Старый или пустой | Вычислить и вернуть разметку — и ничего больше |
| Эффект | Новый: коммит завершён | Согласовать с состоянием всё внешнее: DOM вне React, таймеры, подписки, хранилище |
Тело компонента говорит, каким должен быть экран. Эффект — каким должно быть всё остальное.
Обычно React запускает эффекты, уже дав браузеру нарисовать новый кадр: так пользователь видит результат раньше. Если эффект измеряет DOM и по результату тут же меняет состояние — например, решает, поместится ли подсказка над кнопкой или её надо показать снизу, — пользователь на миг увидит промежуточный кадр. Для таких случаев есть React.useLayoutEffect: та же сигнатура, но вызывается сразу после коммита, до отрисовки кадра, и задерживает её. В консоли песочницы разницы между ними не видно, поэтому правило простое: пишите useEffect, а useLayoutEffect — только когда экран мигает.
Заголовок вкладки — самый маленький «внешний мир»: он живёт в document.title, вне дерева React, и без React его просто присвоили бы после отрисовки. В React это эффект. Обратите внимание на формулировку: не «когда счётчик изменился, обнови заголовок», а «заголовок равен счётчику». Проверьте, сколько раз эффект выполнится на два клика — по счётчику и по теме, которая к заголовку отношения не имеет.
Три раза, и третий — впустую: тема сменилась, счётчик нет, а эффект всё равно записал в заголовок то, что там уже было. Так и задумано: эффект без второго аргумента выполняется после каждого коммита. React не разбирает, что именно изменилось, — он заново приводит внешний мир в соответствие с состоянием. Для заголовка это безобидно, для подписки или запроса на сервер — расточительно.
Второй аргумент — массив зависимостей — говорит React, с чем именно синхронизирован эффект. После каждого коммита он сравнивает элементы массива с прошлыми через Object.is и, если ничего не изменилось, эффект пропускает.
Теперь две строки: клик по теме перерисовал компонент, но count остался прежним, и эффект не запустился. Массив — не «настройка частоты запуска», а честное перечисление того, что эффект читает из компонента; здесь это count. Что бывает, когда список неполный, и почему эффект тогда работает с устаревшими значениями — тема следующей статьи. Пока достаточно правила: в массив попадает всё, что эффект берёт из пропсов и состояния.
Слово «эффект» — от side effect, побочного действия: всё, что функция делает помимо возврата результата. Рендер таких действий не терпит, эффект для них и существует.
На экране всё будет выглядеть верно, и многие так и пишут. Но это та самая нечистота рендера из статьи «Триггер, рендер, коммит»: React вправе вызвать тело и выбросить результат, не дойдя до коммита, или вызвать его дважды в StrictMode — и заголовок покажет состояние, которого пользователь так и не увидел. Эффект же выполняется только после коммита и только для той версии состояния, что попала на экран.
В части II доска подписывалась на document один раз, при старте, — компонентов, которые появляются и исчезают, там не было. В React у подписки есть естественное место — эффект: подписаться нужно, когда компонент на экране, и с теми значениями, что у него сейчас. Ниже слушатель keydown оформляется в эффекте, компонент дважды перерисовывается кликами, а потом мы нажимаем клавишу за читателя — синтетическое событие доходит до document так же, как настоящее. Сколько строк «клавиша» появится?
Три — по одной на каждый рендер. Эффект выполнился после каждого коммита и каждый раз повесил нового слушателя, а старых никто не снимал. Хуже того, каждый слушатель помнит свой count: это обычное замыкание — функция родилась в своём рендере и видит его переменные. Это ровно та течь, о которой предупреждала статья про отписку в части II, только теперь addEventListener вызывает не ваш код при старте, а React после каждого коммита.
Синхронизацию нужно уметь не только начать, но и прекратить. Для этого эффект возвращает функцию — уборку. Прежде чем выполнить эффект заново, React вызывает уборку прошлого запуска; вызовет он её и когда компонент уйдёт с экрана. В каком порядке теперь напечатаются «повешен» и «снят»?
count. Запуски эффекта идут парами: уборка предыдущего перед телом следующего, поэтому в любой момент слушатель ровно один. Уборка — не «код при исчезновении», а вторая половина синхронизации: тело говорит, как начать, уборка — как прекратить, и React чередует их столько раз, сколько потребуется.Всё, что эффект начал — таймер, подписку, соединение, — уборка обязана остановить. Двойной запуск в разработке и гонки запросов — в статье про уборку.
Часы — классический пример: таймер запускается один раз, тикает, пока компонент на экране, и каждым тиком меняет состояние. Внутри интервала — функция-обновитель из главы про состояние: ей всё равно, в каком рендере она создана. Зависимости — пустой массив: эффект не читает из компонента ничего, кроме сеттера, а сеттеры React гарантирует неизменными. Через 900 мс компонент снимается с экрана вызовом root.unmount(). Сколько будет строк «рендер» и сколько — «эффект»?
Пять рендеров и один эффект. Пустой массив значит «эффект ни с чем из компонента не синхронизирован»: сравнивать нечего, значит, и перезапускать незачем — тело выполнилось после первого коммита, уборка — при уходе с экрана. Между ними компонент перерисовался четыре раза, и таймер этого не заметил: он живёт в эффекте, а не в рендере.
Каждый тик — обычный триггер: сеттер, вызванный из таймера, ничем не отличается от сеттера из обработчика. Рендер, коммит — и снова тишина, потому что зависимости не изменились. Так три части эффекта складываются в одну картину:
| Часть | Как выглядит | Что значит | Когда React её вызывает |
|---|---|---|---|
| Тело | () => { … } | Начать синхронизацию | После коммита — если зависимости изменились или массива нет |
| Уборка | return () => { … } | Прекратить синхронизацию | Перед следующим запуском тела и при уходе компонента с экрана |
| Зависимости | [a, b] | С чем синхронизируемся | Сравниваются с прошлыми через Object.is после каждого коммита |
Сеттер в эффекте — тоже триггер. Эффект без зависимостей, который безусловно зовёт сеттер, крутит компонент по кругу: рендер → эффект → рендер…
С пустым массивом беда проявится при уходе компонента с экрана: интервал переживёт компонент и будет звать сеттер того, кого уже нет, — React с версии 18 не ругается, но работа идёт впустую. Без массива хуже: каждый тик — рендер, каждый рендер — ещё один интервал, и число тиков в секунду удваивается с каждым кругом. Не проверяйте это в песочнице: таймеры множатся лавиной, и вкладка зависает быстрее, чем вы дотянетесь до «сброса».
Старые учебники описывают компонент через три момента жизни — появился (componentDidMount), обновился, исчез — и переводят useEffect(fn, []) как «код при появлении». Перевод неверный, и цена ошибки — тихий баг. Компонент ниже подключается к комнате чата (подключение изображает console.log) и делает это «при появлении». Родитель переключает комнату. Что напечатается и что будет на экране?
На экране «новости», а подключение — к «общей», и никакого переподключения. Эффект выполнился при появлении, как и было заказано; о том, что name изменился, он не знает — массив пуст. Компонент честно показывает новую комнату, а слушает старую. Чтобы найти такой баг, нужно заметить, что сообщения приходят не оттуда, — на экране-то всё правильно.
В терминах синхронизации ошибка видна сразу: эффект читает name, значит, синхронизирован с name и должен об этом сказать. Тогда при смене комнаты React сам сделает нужное: уборка отключит старую, тело подключит новую.
Отключение от «общей», подключение к «новостям» — всё, чего не хватало, и без единого if. Компонент по-прежнему ничего не знает о «моментах жизни»: у него есть состояние и описание, каким при этом состоянии должен быть внешний мир. Сколько раз React начнёт и прекратит синхронизацию — его дело: один раз, если комната не меняется, десять — если пользователь щёлкает по списку. Полноту массива проверяет линтер: правило react-hooks/exhaustive-deps подчёркивает пустой массив, когда эффект читает name. Компилятор здесь молчит — для него зависимости обычный массив.
Та же мысль объясняет, почему в разработке React запускает эффект при появлении дважды: в StrictMode он вызывает тело, уборку и снова тело. Эффект, написанный как синхронизация, от этого не страдает: подключился, отключился, подключился. Эффект, написанный как «один раз при появлении», ломается — и это сигнал, что он написан неправильно. Подробности — в статье про уборку.
И последнее. Если результат можно вычислить прямо в рендере из пропсов и состояния — отсортировать список, сложить сумму, — внешнего мира нет, и эффект не нужен вовсе: он лишь добавит рендер. Когда эффект лишний, а когда без него не обойтись — отдельная статья главы.
Компонент не «монтируется» и не «обновляется» — он рендерится с разным состоянием. Эффект привязан не к моментам его жизни, а к значениям, которые читает.
- Эффект — четвёртый шаг после триггера, рендера и коммита: React вызывает его, когда DOM уже обновлён. Строка из тела компонента всегда раньше строки из эффекта.
- Эффект описывает, каким должен быть внешний мир при текущем состоянии:
document.title, подписка, таймер, хранилище. Всё, что запрещено в теле, уместно в эффекте. - Без массива зависимостей эффект выполняется после каждого коммита. Массив перечисляет, что эффект читает из компонента, и React пропускает запуск, если по
Object.isничего не изменилось. - Уборка — вторая половина синхронизации: React вызывает её перед следующим запуском тела и при уходе компонента с экрана. Всё, что эффект начал, уборка останавливает.
- Пустой массив — не «при появлении», а «ни с чем не синхронизирован»: эффект с
[], читающий пропс, молча продолжит работать со старым значением. - Компонент не знает о моментах своей жизни; эффект привязан к значениям, а не к монтированию — поэтому переживает и смену пропсов, и двойной запуск в разработке.
1/4В теле компонента первой строкой стоит console.log("рендер"), в useEffect без зависимостей — console.log("эффект"), а обработчик клика печатает «клик» и вызывает сеттер. В каком порядке появятся строки после клика?
2/4Эффект без массива зависимостей записывает count в document.title. У компонента есть и второе состояние — dark. Пользователь переключил тему, count не менялся. Выполнится ли эффект?
3/4Эффект подписывается на keydown и возвращает уборку, которая снимает слушателя. Компонент отрисовался, затем дважды перерисовался. Что окажется в консоли, если тело печатает «подписка», а уборка — «отписка»?
4/4Почему useEffect(fn, []) — не то же самое, что «выполнить при появлении компонента»?
Тема на всей странице
Тема хранится в состоянии компонента, а стили страницы читают её из атрибута data-theme на <body> — это внешний мир, до которого разметка компонента не дотягивается. Напишите эффект, который держит document.body.dataset.theme равным theme, и добавьте в него console.log. Клики по счётчику эффект запускать не должны.
Эффект читает из компонента одно значение — theme, поэтому оно и стоит в массиве зависимостей. Клик по счётчику перерисует компонент, но theme не изменится, и React эффект пропустит.
В консоли — «эффект: тема → light» после первого коммита, «эффект: тема → dark» после клика по теме и ничего после клика по счётчику; последняя строка покажет dark. Без массива эффект сработал бы и на счётчик — безвредно, но впустую. С пустым массивом тема записалась бы один раз и после клика осталась бы light.
Слушатель-призрак
Панель подписывается на клавиатуру, пока открыта. Кнопка её закрывает — компонент уходит с экрана, — но нажатие клавиши после этого всё равно печатает строку от панели, которой уже нет. Почините эффект.
Эффект начал синхронизацию — повесил слушателя на document, — но не сказал, как её прекратить. Компонент ушёл с экрана, а слушатель остался: document живёт дольше любого компонента. Нужна уборка, которая снимает того же слушателя.
Теперь при закрытии панели React вызовет уборку, и нажатие через 100 мс ничего не напечатает. Пустой массив здесь честен: эффект не читает из компонента ничего. Обратите внимание, что onKey для removeEventListener должна быть той же функцией, что и для addEventListener, — поэтому она объявлена внутри эффекта, а уборка замыкает её.
Посчитать до запуска
Не запуская пример, посчитайте, сколько строк «эффект A» и сколько «эффект B» окажется в консоли после всех трёх кликов, и в каком порядке они идут внутри одного коммита. Запишите ответ, потом запустите и сверьте.
Четыре строки «эффект A» и три «эффект B». Коммитов четыре: первая отрисовка и три клика. Эффект A без массива выполняется после каждого — четыре раза. Эффект B синхронизирован с count: после первой отрисовки и после двух кликов по «+1» — три раза; клик по «Развернуть» изменил только open, и B пропущен.
Внутри одного коммита эффекты выполняются в порядке объявления: сначала A, потом B. Поэтому консоль выглядит так: A, B (первая отрисовка), A, B (первый клик), A (клик по «Развернуть»), A, B (второй клик).