Свой хук: логика без разметки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Компоненты умеют делиться разметкой — для этого их и придумали. А как поделиться связкой «состояние плюс эффект», если разметка у двух компонентов разная? Отдельного React API для этого нет: свой хук — это обычная функция с именем на use, внутри которой вызываются другие хуки. Главное про неё — что состояние она не делит, а раздаёт: у каждого вызова оно своё.
Строка состояния показывает, есть ли сеть. Кнопка «Сохранить» выключается, когда сети нет. Обоим нужно одно и то же: состояние, подписка на события online и offline и отписка при уходе. Разметка при этом общего не имеет вовсе — <p> против <button disabled>.
Пример печатает нет сети | кнопка выключена: true — оба компонента услышали событие и обновились независимо. Работает. Но одинаковых строк здесь одиннадцать, и общий компонент из них не сделать: вынести можно только то, что одинаково, а одинакова тут не разметка, а поведение.
Возразить можно так: поднять состояние в App и передать online пропсом — приём из статьи «Подъём состояния». Он и правда убирает дубль, но ценой того, что App теперь обязан знать про сеть, а сама связка по-прежнему живёт ровно в одном компоненте и на другой странице недоступна. Нужен способ переиспользовать саму логику, не трогая дерево.
Копипаста опасна не длиной, а тем, что каждую будущую правку придётся вспомнить дважды.
Выделяем повторяющийся кусок и переносим его в функцию. Не в класс, не в обёртку, не в React.createHook — такого просто нет. Функция объявляет состояние, подписывается, отписывается и возвращает то, что нужно снаружи.
Вывод тот же самый, а компоненты сократились до одной содержательной строки каждый. Читаются они теперь как заказ: «мне нужно знать, есть ли сеть». Какие именно события слушать, на чём подписываться и что вернуть в уборке — подробности, которые лежат в одном месте и правятся один раз.
Имя на use — не украшение и не требование движка: JavaScript вызовет функцию с любым именем. По имени работают инструменты. Линтер eslint-plugin-react-hooks и React Compiler считают, что внутри use* могут быть хуки, и применяют к ней правила из прошлой статьи: только верхний уровень, без условий и циклов. Функцию, внутри которой хуков нет, называть use* не надо — об этом в последнем разделе.
Хук вызывает другие хуки, и useOnline для него ничем не отличается от useState. Цепочка может быть любой глубины — важно лишь, что все вызовы идут на верхнем уровне.
Вот место, где интуиция подводит чаще всего. Пусть useCounter хранит число и умеет его увеличивать. Один компонент вызывает этот хук дважды: первый счётчик шагает по единице, второй — по десятке. Внутри хука useState(0) написан один раз. Жмём только первую кнопку, дважды. Что окажется во втором счётчике?
Второй счётчик остался на нуле: рендер Panel: 0 0, потом 1 0, потом 2 0. Одна строка useState(0) в исходнике — две независимые ячейки памяти в компоненте. Хук ничего не хранит: он запрашивает память у того компонента, который его вызвал, и каждый вызов useState внутри каждого вызова хука занимает свою ячейку. Порядок вызовов и есть их адрес — отсюда и растут правила хуков.
Если это кажется знакомым — так и есть. В части I фабрика makeCounter из статьи «Замыкания» на каждый вызов создавала новое окружение, и два счётчика из одной фабрики считали порознь. Здесь ровно та же логика, только память держит не замыкание, а React. Строка const ones = useCounter(1) означает не «подключиться к счётчику», а «завести себе счётчик».
Хук раздаёт логику, а не данные. Чтобы два компонента видели одно значение, состояние поднимают выше.
Из этого же следует, что два разных компонента, вызвавших useOnline, ничего друг о друге не знают: у каждого своё online и своя подписка. В примере выше это было видно — событие offline пришло к обоим слушателям отдельно. Общее у них не значение, а способ его получить.
Соблазн понятный: сделать переменную на уровне модуля — и вот она общая. Общей она действительно станет. Только это уже не состояние: React о такой переменной не знает и перерисовку не запускает.
shared дошло до двойки, а на экране осталось Нажато: 0: рендер был один, при монтировании. Это тот же урок, что и в статье «useState: память компонента», просто в новой декорации — обычная переменная не делает компонент реактивным, где бы она ни лежала.
useOnline возвращал одно значение, и вопроса о форме не было. Как только значений становится два — состояние и способ его менять, — появляется выбор. Самый очевидный вариант компилятор не пропустит.
Диагностика приходит на onClick={toggle}: «Тип "boolean | (() => void)" не может быть назначен для типа "MouseEventHandler<HTMLButtonElement> | undefined"». Это ровно тот случай, ради которого в части III разбирались массивы и кортежи. Литерал [on, toggle] выводится как массив, а не как кортеж: типы элементов сливаются в объединение, и возврат хука получает тип (boolean | (() => void))[]. После деструктуризации toggle — «или булево, или функция», а такое в onClick не годится. При этом код исполняется и лампа исправно загорается: ошибка чисто типовая, JavaScript тут безупречен.
useState этой болезнью не страдает, потому что в @types/react его возврат объявлен кортежем вручную. Своему хуку то же самое нужно сказать явно.
Компилятор молчит: возврат стал 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 мс. Сколько строк ищем: будет в выводе и сколько раз отрисуется компонент?
Отрисовок пять, а поиск один: ищем: реа. Каждое нажатие меняет query и перезапускает эффект внутри хука, а уборка гасит прошлый таймер — приём из статьи «Уборка, двойной запуск и гонки запросов», только теперь он спрятан в хуке и в компоненте не виден. Пятая отрисовка — та, в которой отложенное значение наконец догнало набранное.
Про типы здесь важно, что T нигде не назван. Компилятор выводит его из первого аргумента ровно так, как описано в статье «Как компилятор угадывает T»: передали строку — вернётся строка, передадите число — число. Проверить это можно, попросив невозможного.
Первая строка проходит молча, вторая даёт ошибку про number и string: хук вернул то же, что получил. Никакого any, никаких утверждений типа — дженерик переносит тип через хук, как перенёс бы через обычную функцию.
Внутри хука действуют те же правила зависимостей: эффект не знает, что его вынесли из компонента.
После первого своего хука появляется соблазн назвать use* всё, что вынесено из компонента. Признак ровно один: внутри вызывается хук. Не «связано с React», не «используется в компоненте», а именно вызов useState, useEffect, useRef, useContext или другого use*.
| Что внутри функции | Что это | Как называть |
|---|---|---|
useState, useEffect, useRef, другой хук | хук | useOnline — с use в начале |
| только вычисления над аргументами | обычная функция | formatPrice — без use |
| возвращает разметку | компонент | StatusBar — с большой буквы |
Ошибка в имени стоит дороже, чем кажется, и портит дело в обе стороны. Назвав use* чистую функцию, вы запретили себе вызывать её в цикле и в условии: линтер верит имени и будет прав по своим правилам. Спрятав useState внутрь функции getWidth(), вы, наоборот, обманули и линтер, и React Compiler — проверки правил хуков там, где они обязательны, просто выключились.
Когда выносить? Хук окупается, если связка повторяется во втором месте или если компонент стало трудно читать из-за подписок. Хук ради одного вызова — лишний слой: логика уехала из компонента, а понимать её всё равно приходится целиком. Зато как только появляется второй вызов, это уже не рефакторинг «на будущее», а устранение копипасты, которая иначе разойдётся по проекту.
useOnline из этой статьи держит копию внешнего значения в своём состоянии, и копия может разойтись с источником. React предлагает для таких случаев useSyncExternalStore: вы даёте ему функцию подписки и функцию чтения, а он сам решает, когда перечитать.
Запустите — и увидите разницу с примерами из первых разделов. Там наше поддельное событие 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 так, чтобы каждый компонент стал в одну содержательную строку, а вывод примера не изменился. Клавишу хук должен принимать параметром — значит, подумайте и о зависимостях эффекта.
Всё, что повторяется, переезжает в функцию; наружу отдаётся одно булево значение. Единственное содержательное решение — что делать с key: он приходит аргументом, значит попадает в зависимости эффекта, иначе при смене клавиши подписка останется на старой.
Компоненты теперь не знают ни про keydown, ни про уборку. Обратите внимание, что состояние у них по-прежнему своё: два вызова useKeyPressed("Shift") — две подписки и две ячейки. Это не расточительство, а цена независимости; когда подписок станет много, их выносят на уровень выше или в useSyncExternalStore.
Успокоить компилятор одним словом
Хук useField возвращает значение поля и обработчик изменения. На строку с <input> компилятор выдаёт сразу две диагностики — про value и про onChange, — хотя в браузере поле работает как ни в чём не бывало. Найдите причину и почините хук, не трогая компонент Form.
Причина не в компоненте: возврат [value, onChange] выведен массивом, элементы которого «строка или обработчик». Поэтому name не годится в value, а onNameChange — в onChange. Лечится добавлением as const.
Или, если хочется видеть тип в подсказке редактора и запретить себе случайно вернуть третий элемент, — явной аннотацией возврата.
Оба варианта делают одно и то же: возвращают кортеж, в котором позиция определяет тип. Разница только в том, что as const даёт ещё и readonly.
usePrevious: значение с прошлой отрисовки
Напишите дженерик-хук usePrevious<T>(value: T): T | undefined, который возвращает то, что было в value на предыдущей отрисовке. Состояние тут не подойдёт — оно вызовет лишнюю перерисовку; понадобится useRef из статьи «Ссылки на узлы» и эффект.
Ref переживает отрисовку и не запускает новую. Запись в него делаем в эффекте — то есть уже после того, как React показал разметку; значит, во время самой отрисовки в ref.current лежит значение с прошлого раза.
Заменить useRef на useState здесь нельзя: сеттер в эффекте вызвал бы вторую отрисовку после каждой первой. А тип T | undefined честно сообщает, что при первой отрисовке предыдущего значения ещё нет — компилятор не даст об этом забыть.