Правила хуков и почему они такие

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

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

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

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

Состояние доски в части II вы держали в одном объекте: state.tasks, state.filter — у каждого значения было имя, и по имени вы его доставали. React так не может. К моменту запуска const [count, setCount] = useState(0) — это обычный вызов функции, и слова count в нём нет: имя осталось в исходнике, а до useState доезжают только аргументы.

Значит, различать ячейки состояния одного компонента можно только по порядку вызова. Механизм умещается в пятнадцать строк — соберём его целиком, тогда правило перестанет быть требованием сверху.

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

Вся суть — в двух строках: cursor обнуляется перед каждым рендером и увеличивается на каждом вызове. Первый useCell в рендере всегда получает ячейку 0, второй — ячейку 1. Ни имя переменной, ни начальное значение на выбор ячейки не влияют: initial вообще читается только тогда, когда ячейки ещё нет.

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

Теперь спрячем один вызов в условие — ровно то, что запрещает правило. Прогноз перед запуском: что напечатает второй рендер?

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

Второй рендер печатает гость Рико. Пропущенный вызов не оставил дырки: курсор начал с нуля, и useCell(37) — теперь первый вызов в рендере — забрал ячейку 0 с чужой строкой. Всё, что стояло за ним в очереди, съехало бы точно так же.

Заодно посмотрите на тип: у age он number, а внутри строка. Это наше as T соврало компилятору — тот самый случай из статьи «as и @ts-expect-error». Настоящий React честнее не станет: список ячеек и для него нетипизированная память.

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

Прогноз: компонент показывает «Здравствуйте», мы переключаем formal на false. Что окажется на экране и в выводе?

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

Ветка сменилась, а текст остался. Аргумент useState — это начальное значение, и читается оно только при первой отрисовке компонента: ячейка 0 уже занята строкой «Здравствуйте», поэтому «Привет» просто выбрасывается. Компонент честно перерисовался с новым пропсом и показал старое состояние.

Правило нарушено, React молчит: он ловит не «хук в условии», а только сбившийся счёт вызовов. Всё остальное — на вашей совести и на совести линтера.

Компилятор проверяет типы, а не порядок вызовов во времени. Выражение formal ? useState("Здравствуйте") : useState("Привет") безупречно: обе ветки возвращают одну и ту же пару [string, Dispatch<SetStateAction<string>>], деструктуризация корректна, ошибок нет и быть не может. Правило хуков — ограничение на поток выполнения, а такие вещи система типов не выражает.

Ловит их линтер: правило rules-of-hooks из eslint-plugin-react-hooks читает текст функции и ругается на любой вызов хука не с верхнего уровня. Про линтеры вы читали в части I, про typescript-eslint — в части III; здесь тот самый случай, когда линтер знает о коде больше компилятора.

Стоит числу вызовов измениться между рендерами — и React замечает нарушение сразу. Здесь второй useState живёт внутри if, а условие приходит пропсом: первая отрисовка вызывает два хука, вторая — один.

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

На переключении рендер обрывается, дерево под корнем размонтируется — кадр примера пустеет. Обратите внимание на вывод: строка про один хук напечатана дважды. Наткнувшись на ошибку, React отрисовывает компонент ещё раз, прежде чем сдаться, — поэтому console.log в теле компонента и повторяется.

А вот текста ошибки в выводе нет — на его месте заглушка «Uncaught — ошибка выброшена из подключённой библиотеки». Бросил ошибку сам React, то есть подключённый к песочнице файл: происхождение у песочницы своё, такой файл для неё чужой, и у его ошибок браузер вырезает и текст, и место. Проходит только соседняя строка — её React печатает сам и советует в ней поставить границу ошибок. Полную фразу вы прочитаете в консоли своего проекта: в отладочной сборке — целиком, в боевой — номером, который ищут на react.dev/errors.

Что случилосьБоевая сборкаОтладочная сборка
Хуков вызвано меньше, чем в прошлый раз, но хотя бы один#300Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
Хуков вызвано больше, чем в прошлый раз#310Rendered more hooks than during the previous render.
Хук вызван вне рендера — из обработчика или обычной функции#321Invalid hook call. Hooks can only be called inside of the body of a function component.
Не вызвано ни одного хука там, где раньше былитишинатишина — и это следующий раздел

Когда хуков стало больше, отладочная сборка перед ошибкой печатает ещё и таблицу «Previous render / Next render» с именами хуков по номерам — по ней видно, на каком вызове разошёлся порядок.

Фраза про «accidental early return» в тексте ошибки появилась не случайно: проверка «нечего показывать» первой строкой — привычка из обычных функций, и хуки под ней оказываются в условии, только записанном иначе. Но есть случай, когда React не скажет ни слова.

В примере ниже Card возвращает заглушку до единственного хука. Сценарий: добавляем штуку (станет 2), снимаем выбор, возвращаем тот же самый товар. Прогноз: React упадёт с ошибкой, покажет 2 или покажет что-то другое?

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

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

Компонент при этом никуда не уходил: на своём месте в дереве стоял тот же Card, и по правилам из статьи «Когда состояние сохраняется, а когда сбрасывается» состояние обязано было уцелеть. Его убило нарушение правила хуков, а не перестановка в дереве.

Лечится перестановкой двух строк: хук поднимается над возвратом.

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

Теперь вызов ровно один в любом рендере — и двойка переживает и снятие выбора, и возврат товара. Заодно видно, что при product === null компонент всё равно отрисовывается: ранний возврат прекращает построение разметки, а не работу хуков.

Если состояние, наоборот, должно сбрасываться на каждом новом товаре, это делают ключом: key={product.id} в том месте, где рисуется Card, — а не условным хуком.

Правило целиком звучит так: хуки вызываются только на верхнем уровне функции-компонента или своего хука, и в каждом рендере одни и те же, в одном и том же порядке. Всё остальное — следствия.

Место вызоваМожноПочему
Верхний уровень функции-компонентадапорядок вызовов одинаков в каждом рендере
Верхний уровень своего хука (имя с use)даего вызовы просто встают в общий ряд компонента — об этом следующая статья
Внутри if, for, && или тернарного операторанетчисло и порядок вызовов начинают зависеть от данных
После раннего returnнетто же условие, только записанное иначе
В обработчике события, в колбэке, в setTimeoutнетрендер к этому моменту закончился, и React не знает, чьи ячейки брать
В обычной функции — не компоненте и не хукенету такой функции нет своего места в дереве, а значит и своих ячеек
В классовом компонентенету класса другой механизм состояния (см. «Границы ошибок»)

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

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

В боевой сборке это «Minified React error #321», в отладочной — «Invalid hook call. Hooks can only be called inside of the body of a function component» с тремя подсказками, из которых вам подходит вторая: «You might be breaking the Rules of Hooks».

Отсюда же требование к именам: компонент — с большой буквы, свой хук — с приставки use. Линтер не выполняет ваш код, он читает текст, и единственный признак, по которому он отличает компонент от обычной функции, — имя. Назовите функцию getUser, вызовите в ней useState — предупреждения не будет, а React упадёт на первом же запуске.

React Compiler (глава 7) на компоненте, который нарушает правило, просто отказывается работать: он оптимизирует лишь тот код, где порядок вызовов гарантирован.

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

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

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

Ветку или строку списка — в отдельный компонент. Если двум веткам нужны разные хуки, значит это два разных компонента. Хук возвращается на верхний уровень, просто уровень теперь свой — и React смонтирует нужный компонент, а ненужный размонтирует вместе со всеми его ячейками.

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

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

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

Единственное честное «иногда» — когда ветки различаются целиком: разные данные, разные подписки, разная разметка. Тогда и компонент должен быть другой. Из этого правила есть ровно одно исключение — use(): он устроен иначе, ячейки в списке хуков не занимает и потому вызывается хоть в условии. О нём отдельная статья в этой главе.

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

1/4Почему React вообще не позволяет вызывать хук внутри if?

2/4Компонент вызывает formal ? useState("Здравствуйте") : useState("Привет"). Он отрисовался при formal === true, потом пропс стал false. Что произойдёт?

3/4Компонент начинается с if (product === null) return <p>пусто</p>;, ниже — единственный useState. Пропс стал null, а потом вернулся прежним. Что увидит читатель?

4/4Где хук вызывать можно?

Хук в цикле

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

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

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

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

key здесь не формальность: по ключу React решает, какой строке принадлежит какая ячейка состояния, когда строки добавляются и удаляются.

Условие вокруг эффекта

Подписка нужна только во включённом состоянии, поэтому useEffect завёрнут в if. Запустите пример, посмотрите на ошибку и почините компонент, не меняя его поведения: при enabled === false в консоль ничего писаться не должно.

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

Обёртка вокруг вызова меняет число хуков: при enabled === true их два, при false — один. Условие переезжает внутрь эффекта, а enabled уходит в зависимости — иначе эффект не узнает о переключении.

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

Пустой массив зависимостей тут был бы второй ошибкой: эффект прочитал бы enabled один раз и навсегда — это устаревшее значение из статьи «Зависимости и устаревшие значения».

Предсказать съехавшие ячейки

Перед вами мини-React из первого раздела и компонент с тремя вызовами, один из которых спрятан в условие. Скажите, что напечатает второй рендер, — и только потом запускайте.

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

Первый рендер занимает три ячейки: 0 = "Отчёт", 1 = "подробно", 2 = 320. Во втором рендере условный вызов пропадает, курсор идёт 0, 1 — и width получает ячейку 1, то есть строку «подробно».

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

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