Свой хук: логика без разметки

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

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

Компоненты умеют делиться разметкой — для этого их и придумали. А как поделиться связкой «состояние плюс эффект», если разметка у двух компонентов разная? Отдельного React API для этого нет: свой хук — это обычная функция с именем на use, внутри которой вызываются другие хуки. Главное про неё — что состояние она не делит, а раздаёт: у каждого вызова оно своё.

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

Строка состояния показывает, есть ли сеть. Кнопка «Сохранить» выключается, когда сети нет. Обоим нужно одно и то же: состояние, подписка на события online и offline и отписка при уходе. Разметка при этом общего не имеет вовсе — <p> против <button disabled>.

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

Пример печатает нет сети | кнопка выключена: true — оба компонента услышали событие и обновились независимо. Работает. Но одинаковых строк здесь одиннадцать, и общий компонент из них не сделать: вынести можно только то, что одинаково, а одинакова тут не разметка, а поведение.

Возразить можно так: поднять состояние в App и передать online пропсом — приём из статьи «Подъём состояния». Он и правда убирает дубль, но ценой того, что App теперь обязан знать про сеть, а сама связка по-прежнему живёт ровно в одном компоненте и на другой странице недоступна. Нужен способ переиспользовать саму логику, не трогая дерево.

Копипаста опасна не длиной, а тем, что каждую будущую правку придётся вспомнить дважды.

Выделяем повторяющийся кусок и переносим его в функцию. Не в класс, не в обёртку, не в React.createHook — такого просто нет. Функция объявляет состояние, подписывается, отписывается и возвращает то, что нужно снаружи.

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

Вывод тот же самый, а компоненты сократились до одной содержательной строки каждый. Читаются они теперь как заказ: «мне нужно знать, есть ли сеть». Какие именно события слушать, на чём подписываться и что вернуть в уборке — подробности, которые лежат в одном месте и правятся один раз.

Имя на use — не украшение и не требование движка: JavaScript вызовет функцию с любым именем. По имени работают инструменты. Линтер eslint-plugin-react-hooks и React Compiler считают, что внутри use* могут быть хуки, и применяют к ней правила из прошлой статьи: только верхний уровень, без условий и циклов. Функцию, внутри которой хуков нет, называть use* не надо — об этом в последнем разделе.

Хук вызывает другие хуки, и useOnline для него ничем не отличается от useState. Цепочка может быть любой глубины — важно лишь, что все вызовы идут на верхнем уровне.

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

Вот место, где интуиция подводит чаще всего. Пусть useCounter хранит число и умеет его увеличивать. Один компонент вызывает этот хук дважды: первый счётчик шагает по единице, второй — по десятке. Внутри хука useState(0) написан один раз. Жмём только первую кнопку, дважды. Что окажется во втором счётчике?

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

Второй счётчик остался на нуле: рендер Panel: 0 0, потом 1 0, потом 2 0. Одна строка useState(0) в исходнике — две независимые ячейки памяти в компоненте. Хук ничего не хранит: он запрашивает память у того компонента, который его вызвал, и каждый вызов useState внутри каждого вызова хука занимает свою ячейку. Порядок вызовов и есть их адрес — отсюда и растут правила хуков.

Если это кажется знакомым — так и есть. В части I фабрика makeCounter из статьи «Замыкания» на каждый вызов создавала новое окружение, и два счётчика из одной фабрики считали порознь. Здесь ровно та же логика, только память держит не замыкание, а React. Строка const ones = useCounter(1) означает не «подключиться к счётчику», а «завести себе счётчик».

Хук раздаёт логику, а не данные. Чтобы два компонента видели одно значение, состояние поднимают выше.

Из этого же следует, что два разных компонента, вызвавших useOnline, ничего друг о друге не знают: у каждого своё online и своя подписка. В примере выше это было видно — событие offline пришло к обоим слушателям отдельно. Общее у них не значение, а способ его получить.

Соблазн понятный: сделать переменную на уровне модуля — и вот она общая. Общей она действительно станет. Только это уже не состояние: React о такой переменной не знает и перерисовку не запускает.

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

shared дошло до двойки, а на экране осталось Нажато: 0: рендер был один, при монтировании. Это тот же урок, что и в статье «useState: память компонента», просто в новой декорации — обычная переменная не делает компонент реактивным, где бы она ни лежала.

useOnline возвращал одно значение, и вопроса о форме не было. Как только значений становится два — состояние и способ его менять, — появляется выбор. Самый очевидный вариант компилятор не пропустит.

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

Диагностика приходит на onClick={toggle}: «Тип "boolean | (() => void)" не может быть назначен для типа "MouseEventHandler<HTMLButtonElement> | undefined"». Это ровно тот случай, ради которого в части III разбирались массивы и кортежи. Литерал [on, toggle] выводится как массив, а не как кортеж: типы элементов сливаются в объединение, и возврат хука получает тип (boolean | (() => void))[]. После деструктуризации toggle — «или булево, или функция», а такое в onClick не годится. При этом код исполняется и лампа исправно загорается: ошибка чисто типовая, JavaScript тут безупречен.

useState этой болезнью не страдает, потому что в @types/react его возврат объявлен кортежем вручную. Своему хуку то же самое нужно сказать явно.

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

Компилятор молчит: возврат стал readonly [boolean, () => void], позиция снова несёт тип. Второй способ — написать тип возврата руками: function useToggle(initial: boolean): [boolean, () => void]. Он многословнее, зато читается в подсказке редактора и не даёт случайно вернуть лишний элемент. Про сам as const — в статье «readonly и as const: неизменяемость в типах».

ВозвратКогда уместенЧто с именами
одно значениехук отвечает на один вопросимя выбирает вызывающий: const online = useOnline()
кортежровно два значения, и второе — способ изменить первоеимена задаёт вызывающий: const [open, toggleOpen] = useToggle(false)
объекттри и больше, часть нужна не всегдаимена фиксированы хуком: const { data, error, loading } = useLoad()

Кортеж хорош до двух элементов. Дальше позиции перестают запоминаться, и объект честнее.

Раз хук — функция, дженерики работают в нём как везде. Классический случай: отложить значение, пока оно не перестало меняться. Ниже читатель нажимает кнопку три раза подряд с интервалом в 50 мс, а задержка хука — 150 мс. Сколько строк ищем: будет в выводе и сколько раз отрисуется компонент?

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

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

Про типы здесь важно, что T нигде не назван. Компилятор выводит его из первого аргумента ровно так, как описано в статье «Как компилятор угадывает T»: передали строку — вернётся строка, передадите число — число. Проверить это можно, попросив невозможного.

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

Первая строка проходит молча, вторая даёт ошибку про number и string: хук вернул то же, что получил. Никакого any, никаких утверждений типа — дженерик переносит тип через хук, как перенёс бы через обычную функцию.

Внутри хука действуют те же правила зависимостей: эффект не знает, что его вынесли из компонента.

После первого своего хука появляется соблазн назвать use* всё, что вынесено из компонента. Признак ровно один: внутри вызывается хук. Не «связано с React», не «используется в компоненте», а именно вызов useState, useEffect, useRef, useContext или другого use*.

Что внутри функцииЧто этоКак называть
useState, useEffect, useRef, другой хукхукuseOnline — с use в начале
только вычисления над аргументамиобычная функцияformatPrice — без use
возвращает разметкукомпонентStatusBar — с большой буквы
ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибка в имени стоит дороже, чем кажется, и портит дело в обе стороны. Назвав use* чистую функцию, вы запретили себе вызывать её в цикле и в условии: линтер верит имени и будет прав по своим правилам. Спрятав useState внутрь функции getWidth(), вы, наоборот, обманули и линтер, и React Compiler — проверки правил хуков там, где они обязательны, просто выключились.

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

useOnline из этой статьи держит копию внешнего значения в своём состоянии, и копия может разойтись с источником. React предлагает для таких случаев useSyncExternalStore: вы даёте ему функцию подписки и функцию чтения, а он сам решает, когда перечитать.

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

Запустите — и увидите разницу с примерами из первых разделов. Там наше поддельное событие offline переключало экран, потому что состояние жило в useState и верило событию на слово. Здесь источник правды — navigator.onLine, а его подделать нечем: подписка сработает, React перечитает значение и увидит, что сеть на месте.

  • Свой хук — обычная функция с именем на use; хуком её делает то, что внутри вызываются другие хуки.
  • Хук переиспользует логику, а не данные: у каждого вызова своя память, как у каждого вызова фабрики замыканий — своё окружение.
  • Возврат из двух элементов нужно объявить кортежем — через as const или аннотацию, иначе компилятор выведет массив-объединение.
  • Дженерик в хуке работает как в любой функции: T выводится из аргумента и возвращается наружу без потерь.
  • Функцию без хуков внутри называть use* вредно: имя включает правила хуков там, где они не нужны.
  • Хук окупается со второго вызова; ради одного места он только добавляет слой.

1/4Хук useCounter объявляет внутри себя useState(0). Компонент вызывает его дважды: const a = useCounter(1) и const b = useCounter(10). Что со состоянием?

2/4Хук заканчивается строкой return [on, toggle]; без всяких уточнений. Какой тип возврата выведет компилятор?

3/4Функция calcDiscount(price, percent) только считает число из аргументов, хуков внутри нет. Стоит ли переименовать её в useDiscount, раз её зовут из компонентов?

4/4Автор вынес счётчик в переменную на уровне модуля, а хук только читает её и увеличивает. Что произойдёт при нажатии кнопки?

Вынести подписку на клавишу

Два компонента следят за зажатым Shift и содержат одну и ту же подписку целиком. Вынесите её в хук useKeyPressed(key: string): boolean так, чтобы каждый компонент стал в одну содержательную строку, а вывод примера не изменился. Клавишу хук должен принимать параметром — значит, подумайте и о зависимостях эффекта.

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

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

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

Компоненты теперь не знают ни про keydown, ни про уборку. Обратите внимание, что состояние у них по-прежнему своё: два вызова useKeyPressed("Shift") — две подписки и две ячейки. Это не расточительство, а цена независимости; когда подписок станет много, их выносят на уровень выше или в useSyncExternalStore.

Успокоить компилятор одним словом

Хук useField возвращает значение поля и обработчик изменения. На строку с <input> компилятор выдаёт сразу две диагностики — про value и про onChange, — хотя в браузере поле работает как ни в чём не бывало. Найдите причину и почините хук, не трогая компонент Form.

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

Причина не в компоненте: возврат [value, onChange] выведен массивом, элементы которого «строка или обработчик». Поэтому name не годится в value, а onNameChange — в onChange. Лечится добавлением as const.

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

Или, если хочется видеть тип в подсказке редактора и запретить себе случайно вернуть третий элемент, — явной аннотацией возврата.

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

Оба варианта делают одно и то же: возвращают кортеж, в котором позиция определяет тип. Разница только в том, что as const даёт ещё и readonly.

usePrevious: значение с прошлой отрисовки

Напишите дженерик-хук usePrevious<T>(value: T): T | undefined, который возвращает то, что было в value на предыдущей отрисовке. Состояние тут не подойдёт — оно вызовет лишнюю перерисовку; понадобится useRef из статьи «Ссылки на узлы» и эффект.

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

Ref переживает отрисовку и не запускает новую. Запись в него делаем в эффекте — то есть уже после того, как React показал разметку; значит, во время самой отрисовки в ref.current лежит значение с прошлого раза.

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

Заменить useRef на useState здесь нельзя: сеттер в эффекте вызвал бы вторую отрисовку после каждой первой. А тип T | undefined честно сообщает, что при первой отрисовке предыдущего значения ещё нет — компилятор не даст об этом забыть.