Свои хуки целиком: setState, эффект и уборка

свои хуки≈ 16 минут

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

В «Правилах хуков» мы собрали пятнадцать строк, которые объясняют, почему хук нельзя прятать в условие. Но та миниатюра только читала: ни сеттера, ни эффекта, ни уборки в ней не было, а список ячеек был один на всё приложение. Допишем её до настоящего useState и useEffect — и прогоним один и тот же компонент своей версией и настоящим React, сверив вывод строка в строку.

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

Та миниатюра умела ровно одно: выдать значение из ячейки по номеру вызова. Менять его приходилось руками — cells[1] = 38, — и после этого ещё звать render. Настоящий useState отдаёт вторым элементом сеттер, который делает и то и другое. Весь вопрос в том, за что этот сеттер держится.

За значение держаться бессмысленно: значение — снимок, оно устареет в тот же миг. Держаться надо за ячейку, поэтому в ячейке теперь объект, а не голое число. Прогноз перед запуском: обработчик first получен в первом рендере и нажимается дважды — что напечатает вторая строка и что третья?

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

Третья строка — снова count = 1. Сеттер сработал оба раза, ячейка честно записалась, рендер честно случился, а число не выросло: count внутри замыкания first навсегда равен нулю, и оба раза в ячейку легла одна и та же единица. Это тот самый снимок, который мы уже вскрывали в «Своей очереди обновлений», — локальная переменная того вызова функции, в котором родился обработчик. Но заметьте: очереди здесь нет, наш сеттер перерисовывает сразу, а снимок никуда не делся. Его делает не пачка, а замыкание. Очередь в эту миниатюру мы сознательно не берём — во что это обойдётся, покажет последний раздел.

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

Прогноз: нажатий будет четыре — сколько строк окажется в выводе?

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

Строк четыре: начальный рендер и три из четырёх нажатий. Обновитель вылечил застревание — c приходит из ячейки, а не из замыкания, поэтому два нажатия дают 1 и 2. А последнее нажатие не напечатало ничего: reset положил ноль поверх нуля, Object.is(0, 0) — истина, и рендера не было вовсе. Это правило вы уже видели с другой стороны: объект, изменённый на месте, экран не обновляет, потому что ссылка осталась прежней.

Настоящий React на том же сценарии напечатает пять строк, а не четыре. Сравнение у него то же самое, но сделать его заранее, прямо в сеттере, он может не всегда: сразу после изменения React сперва вызывает компонент и сравнивает уже потом — видит тот же ноль и отказывается от коммита. Тело при этом выполнилось, строка напечаталась; третий reset подряд не дал бы и её. На экран это не влияет, но число строк в консоли здесь по миниатюре не предсказать.

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

Эффект отличается от состояния тем, что во время рендера его выполнять нельзя: рендер обязан быть чистым, а эффект как раз лезет во внешний мир. Значит, хук эффекта во время рендера только подаёт заявку, а выполняется она позже — после коммита. DOM наша миниатюра не правит, поэтому весь её commit и будет разбором этих заявок.

Ячейке для этого нужны два поля, которые мы уже завели: deps — список зависимостей прошлого запуска и cleanup — функция уборки, которую тот запуск вернул. Сравнение зависимостей — поэлементный Object.is, тот же самый, что в сеттере.

Прогноз: комната меняется один раз, потом дважды приходит непрочитанное сообщение. Сколько будет строк «подключились» и сколько «отключились»?

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

Подключений два, отключение одно, а рендеров четыре. Два последних прошли без единой строки эффекта: unread в зависимостях нет, а room не менялся — Object.is("новости", "новости") истинно, и заявка не подаётся. Обратите внимание и на порядок: «рендер» печатается раньше «подключились», потому что тело эффекта выполняется после вызова компонента, а не внутри него.

И ещё деталь, которую в настоящем React разглядеть трудно. Уборка печатает отключились от общий — старую комнату, хотя вызвана она в тот момент, когда комната уже «новости». Ничего загадочного: уборка — замыкание того рендера, в котором эффект запускался, и room в нём тот, прежний. В миниатюре это просто видно: cell.cleanup хранит функцию, созданную в прошлом коммите.

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

Соблазн понятный: сравнивать не зависимости, а саму функцию эффекта — она ведь новая тогда, когда что-то изменилось. Не выйдет. Стрелка в теле компонента создаётся заново в каждом рендере, Object.is на двух таких функциях всегда ложь, и эффект перезапускался бы после каждой отрисовки. Поэтому сравниваются не строки кода, а данные, и список этих данных пишется руками — со всеми последствиями из статьи «Зависимости и устаревшие значения».

У уборки два повода сработать: перед новым запуском того же эффекта и при уходе компонента. Первый мы видели. Но стоит эффектам стать двумя, как возникает вопрос, которого на одном не бывает: в каком порядке?

Наш коммит разбирает заявки по очереди — уборка, тело, уборка, тело. Прогноз: что напечатает смена комнаты?

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

Вышло «A отключились, A подключились, B закрыли, B открыли». Теперь тот же компонент на настоящих хуках — сверьте порядок сами.

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

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

Правится это в одном месте — в коммите. Сам useEffect не меняется: он и так кладёт в очередь не готовую работу, а пару «ячейка плюс эффект», и разделить её на два прохода можно позже. Заодно добавим уборку при уходе: unmount проходит по ячейкам, зовёт всё, что в них осталось, и очищает список.

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

Порядок совпал с настоящим. А в конце видно второе применение уборки: unmount отключил обе подписки, и следующий рендер начал с пустого списка — useState("общий") снова прочитал начальное значение. Ровно это происходит, когда компонент уходит с экрана: его состояние не «сохраняется где-то», его больше нет.

Проверка двойным запуском в разработке похожа на наш unmount, но мягче: React зовёт уборку и тело ещё раз, а ячейки не трогает — состояние проверку переживает.

Одно место осталось откровенно ненастоящим: cells — один массив на всё. Прошлая статья утверждала, что у каждого вызова хука состояние своё; это половина правды. Вторая половина — свой список ячеек у каждого экземпляра компонента, и вот её-то мы и не собрали.

Пока компонент один, это незаметно. Стоит появиться второму — и всплывает случай, который правило хуков не запрещает и запретить не может. Компонент в условии — законная разметка: {withHint && <Hint />} пишут каждый день, и хуки внутри Hint при этом вызываются на верхнем уровне Hint. Прогноз: что напечатает второй рендер?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Counter: подсказка. Картина знакомая, а причина другая: в «Правилах хуков» курсор съезжал внутри одного компонента, здесь — между компонентами. Hint исчез, его ячейка осталась первой в общем списке, и Counter, ставший теперь первым, забрал её себе.

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

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

Столкновение исчезло, и появилось кое-что ещё: bump перерисовал только Counter — строки Hint в выводе после первой нет. Так и должно быть. Сеттер запомнил в owner своего владельца, а не «весь экран»: рендер начинается с того компонента, чьё состояние изменилось. В настоящем React он идёт дальше вниз по дереву, к детям, но вверх и вбок — не идёт.

У React вместо нашей плоской пары экземпляров дерево, и список хуков висит на его узле. Отсюда правила из статьи «Когда состояние сохраняется, а когда сбрасывается»: узел на том же месте — ячейки те же, узел заменился — ячеек нет.

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

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

Теперь проверка, ради которой всё затевалось. Компонент ниже не вызывает useState по имени — он берёт хуки из набора H. Подменив набор, один и тот же компонент можно выполнить сперва нашей миниатюрой, потом настоящим React. Это не трюк ради статьи: внутри React устроен так же — useState из пакета react лишь достаёт текущий набор и зовёт одноимённый метод у него.

Прогноз: совпадёт вывод или разойдётся — и если разойдётся, то на какой строке?

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

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

Заодно понятно, откуда берётся «Invalid hook call» из «Правил хуков». React выставляет набор хуков перед вызовом компонента и убирает сразу после; в обработчике, который сработает через секунду, набора уже нет — звать оттуда просто некого. У нашей миниатюры этой проверки нет, и она бы молча отдала ячейку последнего отрисованного компонента: React в этом месте строже нас.

А теперь туда, где миниатюра расходится с оригиналом. Нажатие меняет два состояния сразу — прогноз: сколько рендеров будет там и сколько здесь?

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

У нас три рендера, у React — два. Наш сеттер перерисовывает немедленно — а это тот самый вариант «разобрать сразу», от которого в «Своей очереди обновлений» ушли к микрозадаче: первое же изменение показывает половинчатую картину, лайк уже прибавился, просмотр ещё нет. React складывает оба обновления в очередь и разбирает её одной пачкой, когда обработчик договорит. Эту очередь мы собирали в главе про состояние, а сюда не взяли, чтобы она не заслоняла ячейки.

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

Последние три строки таблицы — не мелочи, а отдельные машины, и две из них вы уже собирали по отдельности: очередь с пачкой — в главе про состояние, сверку описаний с правкой DOM — в первой главе. Сегодняшняя миниатюра — то, что связывает их с вашей функцией: ячейки, которые переживают её вызов. А следующая пара статей, про use() и Suspense, стоит на другом материале: use() ячеек в этом списке не занимает, и ожидание держится не на них, а на брошенном промисе.

  • Сеттер держится не за значение, а за ячейку: ячейка переживает рендер, поэтому обработчик работает и после его конца. Значение внутри обработчика остаётся снимком — отсюда счётчик, застрявший на единице.
  • Функция-обновитель читает ячейку, а не замыкание. Сеттер с прежним значением (Object.is) рендера не запускает вовсе.
  • Хук эффекта во время рендера только подаёт заявку: сравнивает зависимости поэлементным Object.is и, если они разошлись, кладёт в очередь пару «ячейка плюс эффект».
  • Коммит делает два прохода: сперва все уборки, потом все тела. Уборка — замыкание того рендера, в котором эффект запускался, поэтому она видит старые значения.
  • Список ячеек не один на всех: он свой у каждого экземпляра компонента, а сеттер запоминает владельца и перерисовывает только его ветку.
  • Хуки берутся из «текущего набора», который React выставляет на время рендера и убирает после. Тот же компонент на нашем наборе и на настоящем печатает одно и то же — пока дело не доходит до очереди обновлений.

1/4Обработчик, полученный в первом рендере, вызывает setCount(count + 1). Его нажали дважды подряд, и счётчик остановился на единице. Почему?

2/4Где React вызывает Object.is?

3/4В миниатюре с одним общим списком ячеек разметка {withHint && <Hint />} ломает соседний Counter. Почему настоящий React так не ломается?

4/4У компонента два эффекта, и в одном коммите перезапускаются оба. В каком порядке пойдут уборки и тела?

Свой useRef

В подложке примера лежит миниатюра из статьи. Допишите useRef: он должен возвращать одну и ту же коробку во всех рендерах и не вызывать перерисовку. Сейчас счётчик вызовов компонента застревает на единице, а должен дойти до тройки.

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

Коробка — обычное значение, и хранить её надо там же, где хранится состояние: в ячейке. Разница с useState только в том, что менять её никто не просит, поэтому и сеттера нет, и рендера нет — отсюда и «ref не вызывает перерисовку».

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

Вывод становится 1, 2, 3. Заодно видно, почему запись в ref.current не обновляет экран: сеттера нет, рендера никто не просит — коробка меняется молча. И почему usePrevious из прошлой статьи работает: ячейка переживает рендер, а запись в неё делается уже в эффекте, после него.

useReducer в две строки

В статье «useReducer и размеченные объединения» этот хук выглядел как отдельный механизм. Соберите его на нашей миниатюре — новых ячеек заводить не нужно, всё необходимое уже есть. Сейчас пример печатает одну строку, а должен четыре: 0, 1, 2, 0.

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

Отдельного механизма и нет: useReducer — это useState, у которого обновитель собран из редьюсера и действия.

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

Отсюда следуют и знакомые правила. Редьюсер обязан быть чистой функцией — потому что он выполняется там же, где обновитель, и React вправе вызвать его лишний раз. Действие с тем же результатом не перерисует экран — сработает Object.is в сеттере. А вот стабильности dispatch у нас нет: наш сеттер — новая стрелка в каждом рендере, тогда как React от рендера к рендеру возвращает одну и ту же функцию. Проверить это можно одной строкой — сравнить сеттер с сеттером прошлого рендера.

Предсказать число запусков

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

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

Три раза — после каждого рендера. Строка, которая за это отвечает, одна:

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

user собирается заново в каждом вызове компонента, значит в deps каждый раз лежит новая ссылка. Object.is сравнивает ссылки, а не содержимое: два объекта { name: "Рико" } для него разные, сколько бы одинаковых полей в них ни было. Сравнение вглубь React не делает намеренно — оно стоило бы дороже самого эффекта, а на функциях и вовсе невозможно.

Лечится это тем же, чем и в статье про зависимости: в списке перечисляют примитивы ([user.name]), а объект собирают внутри эффекта. Или запоминают сам объект: useMemo из следующей главы держит в ячейке пару «значение плюс зависимости» и сравнивает их тем же самым Object.is.