Зависимости и устаревшие значения

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

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

Эффект с пустым массивом зависимостей запускается один раз — и навсегда запоминает count той единственной отрисовки, в которой родился. Отсюда счётчики, застрявшие на единице, и запросы со вчерашним фильтром. Второй аргумент useEffect — не расписание запусков, а список того, что эффект читает: React верит ему на слово, а за правдивость отвечаете вы и линтер.

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

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

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

Пять тиков — и единица на экране. Стрелка внутри setInterval родилась в эффекте, эффект — в первой отрисовке, а там count равен нулю. Это тот самый снимок из статьи про состояние-снимок: колбэк уносит с собой значение своей отрисовки. Только здесь его унёс не обработчик клика, который живёт до следующей отрисовки, а эффект, которому пустой массив пообещал: перезапускать не будем.

Каждый тик честно вычисляет 0 + 1 и просит поставить единицу. В первый раз React соглашается и перерисовывает компонент. Новая отрисовка создаёт новый эффект — с новой стрелкой и count = 1, — но запускать его React не станет: массив зависимостей пуст, сравнивать нечего, старый эффект остаётся в силе. Старый интервал продолжает крутиться со старым нулём, и четыре следующих тика просят поставить всё ту же единицу.

В части II этой ловушки не было: интервал читал state.count из живого объекта и всегда видел свежее число. React заменил живой объект снимками — и интервал, созданный один раз, застрял в первом из них.

В обсуждениях это зовут «устаревшим замыканием» — stale closure. Ничего не устарело: замыкание работает ровно как в части I. Устарело наше представление о том, какой count оно видит.

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

Три формы второго аргумента — три разных договора. Компонент ниже держит два состояния и три эффекта, каждый печатает снимок, который видит. Клики идут по расписанию: +1, затем смена темы, затем снова +1. Посчитайте, сколько строк напечатает каждый эффект.

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

Восемь строк. Эффект без массива запустился после каждой из четырёх отрисовок. Эффект с пустым массивом — один раз, после первой. Эффект с [count] — трижды: смену темы он пропустил, потому что count в ней не изменился.

Второй аргументКогда запускаетсяКакой снимок видит
нетпосле каждой отрисовкивсегда свежий
[]один раз, после первой отрисовкипервый — навсегда
[count]после первой и после каждой, где count стал другимпоследней отрисовки, где count менялся

Присмотритесь к третьему эффекту. Он печатает theme, но не перечисляет её. После смены темы он промолчал — а будь на месте console.log запрос или подписка, они бы работали с темой light до следующего изменения счёта. Список соврал, и вранью ничего не помешало: ни ошибки, ни предупреждения. React не читает тело эффекта, он верит списку. Отсюда и правило: перечислять не «когда перезапускать», а «всё, что читаете».

Зависимости — не оптимизация, которую вы выбираете. Это описание эффекта, из которого React сам выводит, когда старый экземпляр ещё годен.

Вернёмся к застрявшему счётчику. Способа два, и они противоположны: либо эффект перестаёт читать count, и пустой массив становится честным, либо перечисляет count и перезапускается при каждом изменении.

Первый способ — функция-обновитель из статьи про очередь обновлений. setCount(c => c + 1) не читает снимок: текущее значение ей передаст React в момент применения. Сама setCount у одного состояния одна на всю жизнь компонента, её перечислять не требуется.

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

Пять тиков — пятёрка на экране, и эффект по-прежнему запущен один раз. Устаревать здесь нечему: эффект не держит ни одного значения из отрисовки, и пустой массив говорит чистую правду.

Тоже работает — но по-другому. Эффект перезапускается после каждого тика: старый интервал останавливается, новый заводится уже с новым count. Остановка — это функция, которую эффект возвращает; следующая статья целиком о ней, здесь она нужна, чтобы интервалы не копились.

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

Шесть строк «эффект запущен» вместо одной. Список честный, значение свежее, цена — пересоздание интервала на каждый тик. Для интервала это терпимо, для подписки на сокет или запроса к серверу — уже расточительно.

Правило выбора простое. Если значение нужно эффекту только чтобы изменить состояние — берите обновитель. Если эффект должен значение увидеть — подписаться на канал с этим именем, запросить данные под этот фильтр, — перечисляйте и перезапускайте. Есть и третий случай: значение нужно видеть, но перезапускать эффект из-за него нельзя. Для него в React есть отдельный инструмент, useEffectEvent, — о нём пятая статья главы.

После части III хочется, чтобы ошибку поймал компилятор. Здесь его хватает не на всё. Типы useEffect описывают форму: первый аргумент — функция, второй — DependencyList, то есть массив только для чтения, либо ничего. Передайте вместо массива число — и диагностика будет настоящей.

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

Компилятор недоволен строкой с count: «Аргумент типа "number" нельзя назначить параметру типа "readonly unknown[]"» — DependencyList он раскрывает до того, чем этот псевдоним объявлен. Но код всё равно выполняется, и заголовок ставится: на первой отрисовке любой второй аргумент срабатывает одинаково. Первая строка вывода при этом не наша, а React: песочница собирает его отладочной сборкой, поэтому предупреждение про «не массив» видно здесь так же, как будет видно у вас в проекте.

А вот [] при эффекте, читающем count, для системы типов безупречен: замыкание над числом — корректная программа, и никакого типа «устаревшее число» не существует. Что лежит внутри массива и чего там не хватает, компилятору безразлично.

Содержимое списка проверяет линтер — правило react-hooks/exhaustive-deps из пакета eslint-plugin-react-hooks: это плагин к тому же ESLint, конфиг которого разбирала статья «typescript-eslint: правила, которых нет у компилятора». Правило читает тело эффекта, собирает всё, что тот берёт из отрисовки, и сверяет со вторым аргументом. Пропустили — предупреждение с именем пропущенного. В песочнице учебника линтера нет, в редакторе он у вас будет, поэтому стоит знать, что он считает «реактивным значением».

Что читает эффектДолжно быть в спискеПочему
пропс или состояниедаменяется от отрисовки к отрисовке
переменная, вычисленная в теле компонентадановая при каждом вызове компонента
функция set… из useStateнетодна и та же на всю жизнь компонента
объект из useRefнеттот же объект в каждой отрисовке
переменная модуля вне компонентанетот отрисовки не зависит
document, window, импортынетк React отношения не имеют

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

Линтер знает о стабильных значениях: setCount и объект из useRef перечислять не заставит — но и перечислить их не запретит.

Object.is сравнивает примитивы по значению, а объекты — по ссылке; два одинаковых литерала — два разных объекта, это часть I. В теле компонента это стреляет неожиданно: литерал, написанный в коде один раз, создаётся заново при каждом вызове функции — то есть при каждой отрисовке.

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

Компонент ниже собирает настройки поиска в объект и кладёт его в зависимости. Кнопка «просто клик» меняет состояние, к поиску не относящееся. Сколько раз сработает эффект?

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

Четыре запуска вместо двух. options в каждой отрисовке новый, и Object.is честно отвечает «не тот». С точки зрения React список не врёт — значение и правда другое, — но эффект перезапускается впустую, а стой там запрос, он уходил бы на сервер после каждого клика по чему угодно.

Лечится это на уровне списка. Перечисляйте примитивы, которые лежат внутри объекта, а сам объект собирайте внутри эффекта. Если настройки не зависят от отрисовки вовсе — выносите их за пределы компонента: переменная модуля создаётся один раз.

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

Три запуска, и ни одного от «просто клика». В списке остались query и limit — примитивы, которые Object.is сравнивает по значению.

Функции, объявленные в теле компонента, устроены так же: новая на каждой отрисовке. useMemo и useCallback умеют удерживать ссылку — о них глава про скорость, — но тянуться к ним первым делом не стоит: чаще всего достаточно перечислить примитивы.

  • Эффект — замыкание над снимком той отрисовки, в которой создан; с пустым массивом он видит первый снимок навсегда.
  • Второй аргумент useEffect — не расписание, а список того, что эффект читает; React сравнивает его поэлементно через Object.is и в тело эффекта не заглядывает.
  • Нет массива — после каждой отрисовки; [] — один раз; [count] — после тех отрисовок, где count стал другим.
  • Устаревшее значение чинится двумя способами: перестать его читать (функция-обновитель) или честно перечислить и перезапускать эффект.
  • Компилятор проверяет форму второго аргумента, содержимое списка — линтер, правило react-hooks/exhaustive-deps; отключать его — врать React.
  • Объекты и функции из тела компонента новые в каждой отрисовке: перечисляйте примитивы, а объект собирайте внутри эффекта.

1/4Эффект с пустым массивом зависимостей заводит setInterval, который каждые 100 мс вызывает setCount(count + 1). Состояние было 0. Что окажется на экране через секунду?

2/4У useEffect не указан второй аргумент вообще. Когда будет выполняться такой эффект?

3/4В теле компонента написано const options = { limit: 10 }, а эффект объявлен с зависимостями [options]. Кнопка меняет состояние, которое к options не относится. Что произойдёт с эффектом после клика?

4/4Эффект читает и count, и theme, а в зависимостях указан только [count]. Кто сообщит об ошибке?

Заголовок вкладки отстаёт

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

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

Эффект читает done, а список зависимостей пуст — значит, он запустился один раз с нулём и больше не запускался. Перечисляем то, что читаем.

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

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

Сохранение с забытым фильтром

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

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

Вывод — три строки:

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

Смена фильтра ничего не сохранила: filter не в списке, а tasks не менялся. Между вторым и третьим кликом сохранённым остался бы фильтр «все» — пользователь потерял бы свой выбор. Третья строка показывает верный фильтр лишь потому, что новая отрисовка создала новый эффект со свежим снимком.

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

Теперь строк четыре, и каждое изменение доски попадает в сохранение. Линтер подчеркнул бы исходный список сразу: эффект читает filter, а в зависимостях его нет.

Лента, которая грузится на каждый лайк

Лента «загружает» страницу при изменении параметров запроса. Но загрузка печатается и после каждого лайка, который к параметрам не относится. Сделайте так, чтобы эффект срабатывал только при смене страницы, не меняя тела эффекта.

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

params — новый объект в каждой отрисовке, поэтому [params] перезапускает эффект после любого изменения состояния. Перечисляем примитивы, которые эффект на самом деле читает.

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

Линтер принимает и такую запись: он умеет следить за отдельными полями. Ещё чище — не собирать объект в теле компонента вовсе: perPage не зависит от отрисовки, его место в константе модуля, а в списке тогда остаётся один page.