Правила хуков и почему они такие
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
React не знает, как называются ваши переменные состояния. У компонента есть список ячеек, и единственное, чем один useState отличается от другого, — номер вызова. Отсюда выводится всё правило хуков целиком, а заодно и самое неприятное: нарушение падает с ошибкой далеко не всегда — иногда оно молча отдаёт не то значение.
Состояние доски в части II вы держали в одном объекте: state.tasks, state.filter — у каждого значения было имя, и по имени вы его доставали. React так не может. К моменту запуска const [count, setCount] = useState(0) — это обычный вызов функции, и слова count в нём нет: имя осталось в исходнике, а до useState доезжают только аргументы.
Значит, различать ячейки состояния одного компонента можно только по порядку вызова. Механизм умещается в пятнадцать строк — соберём его целиком, тогда правило перестанет быть требованием сверху.
Вся суть — в двух строках: cursor обнуляется перед каждым рендером и увеличивается на каждом вызове. Первый useCell в рендере всегда получает ячейку 0, второй — ячейку 1. Ни имя переменной, ни начальное значение на выбор ячейки не влияют: initial вообще читается только тогда, когда ячейки ещё нет.
Настоящий React держит не один массив на всё приложение, а свой список ячеек у каждого компонента в дереве. Принцип обхода тот же: курсор с начала, шаг на каждый вызов хука.
Теперь спрячем один вызов в условие — ровно то, что запрещает правило. Прогноз перед запуском: что напечатает второй рендер?
Второй рендер печатает гость Рико. Пропущенный вызов не оставил дырки: курсор начал с нуля, и useCell(37) — теперь первый вызов в рендере — забрал ячейку 0 с чужой строкой. Всё, что стояло за ним в очереди, съехало бы точно так же.
Заодно посмотрите на тип: у age он number, а внутри строка. Это наше as T соврало компилятору — тот самый случай из статьи «as и @ts-expect-error». Настоящий React честнее не станет: список ячеек и для него нетипизированная память.
От сломанного мини-React ждёшь, что настоящий на такой код закричит. Иногда он и правда кричит, но далеко не всегда — и это хуже. Ниже useState вызван внутри тернарного оператора, то есть строго против правила. При этом вызов в любой ветке ровно один: счёт вызовов не меняется, придраться React не к чему.
Прогноз: компонент показывает «Здравствуйте», мы переключаем formal на false. Что окажется на экране и в выводе?
Ветка сменилась, а текст остался. Аргумент useState — это начальное значение, и читается оно только при первой отрисовке компонента: ячейка 0 уже занята строкой «Здравствуйте», поэтому «Привет» просто выбрасывается. Компонент честно перерисовался с новым пропсом и показал старое состояние.
Правило нарушено, React молчит: он ловит не «хук в условии», а только сбившийся счёт вызовов. Всё остальное — на вашей совести и на совести линтера.
Компилятор проверяет типы, а не порядок вызовов во времени. Выражение formal ? useState("Здравствуйте") : useState("Привет") безупречно: обе ветки возвращают одну и ту же пару [string, Dispatch<SetStateAction<string>>], деструктуризация корректна, ошибок нет и быть не может. Правило хуков — ограничение на поток выполнения, а такие вещи система типов не выражает.
Ловит их линтер: правило rules-of-hooks из eslint-plugin-react-hooks читает текст функции и ругается на любой вызов хука не с верхнего уровня. Про линтеры вы читали в части I, про typescript-eslint — в части III; здесь тот самый случай, когда линтер знает о коде больше компилятора.
Стоит числу вызовов измениться между рендерами — и React замечает нарушение сразу. Здесь второй useState живёт внутри if, а условие приходит пропсом: первая отрисовка вызывает два хука, вторая — один.
На переключении рендер обрывается, дерево под корнем размонтируется — кадр примера пустеет. Обратите внимание на вывод: строка про один хук напечатана дважды. Наткнувшись на ошибку, React отрисовывает компонент ещё раз, прежде чем сдаться, — поэтому console.log в теле компонента и повторяется.
А вот текста ошибки в выводе нет — на его месте заглушка «Uncaught — ошибка выброшена из подключённой библиотеки». Бросил ошибку сам React, то есть подключённый к песочнице файл: происхождение у песочницы своё, такой файл для неё чужой, и у его ошибок браузер вырезает и текст, и место. Проходит только соседняя строка — её React печатает сам и советует в ней поставить границу ошибок. Полную фразу вы прочитаете в консоли своего проекта: в отладочной сборке — целиком, в боевой — номером, который ищут на react.dev/errors.
| Что случилось | Боевая сборка | Отладочная сборка |
|---|---|---|
| Хуков вызвано меньше, чем в прошлый раз, но хотя бы один | #300 | Rendered fewer hooks than expected. This may be caused by an accidental early return statement. |
| Хуков вызвано больше, чем в прошлый раз | #310 | Rendered more hooks than during the previous render. |
| Хук вызван вне рендера — из обработчика или обычной функции | #321 | Invalid 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 или покажет что-то другое?
Ошибки нет, а счётчик вернулся к единице. Проверку на несовпадение React делает, только если в этом рендере был вызван хотя бы один хук: сравнивать иначе не с чем. Рендер без единого вызова он понимает буквально — «компонент больше не пользуется состоянием» — и обнуляет его список ячеек. Когда товар возвращается, useState заводит ячейку заново, с начальным значением.
Компонент при этом никуда не уходил: на своём месте в дереве стоял тот же Card, и по правилам из статьи «Когда состояние сохраняется, а когда сбрасывается» состояние обязано было уцелеть. Его убило нарушение правила хуков, а не перестановка в дереве.
Лечится перестановкой двух строк: хук поднимается над возвратом.
Теперь вызов ровно один в любом рендере — и двойка переживает и снятие выбора, и возврат товара. Заодно видно, что при product === null компонент всё равно отрисовывается: ранний возврат прекращает построение разметки, а не работу хуков.
Если состояние, наоборот, должно сбрасываться на каждом новом товаре, это делают ключом: key={product.id} в том месте, где рисуется Card, — а не условным хуком.
Правило целиком звучит так: хуки вызываются только на верхнем уровне функции-компонента или своего хука, и в каждом рендере одни и те же, в одном и том же порядке. Всё остальное — следствия.
| Место вызова | Можно | Почему |
|---|---|---|
| Верхний уровень функции-компонента | да | порядок вызовов одинаков в каждом рендере |
Верхний уровень своего хука (имя с use) | да | его вызовы просто встают в общий ряд компонента — об этом следующая статья |
Внутри if, for, && или тернарного оператора | нет | число и порядок вызовов начинают зависеть от данных |
После раннего return | нет | то же условие, только записанное иначе |
В обработчике события, в колбэке, в setTimeout | нет | рендер к этому моменту закончился, и React не знает, чьи ячейки брать |
| В обычной функции — не компоненте и не хуке | нет | у такой функции нет своего места в дереве, а значит и своих ячеек |
| В классовом компоненте | нет | у класса другой механизм состояния (см. «Границы ошибок») |
Три последние строки таблицы — уже другая ошибка, не про счёт вызовов. Хук вне рендера падает потому, что вызывать его некому: на время отрисовки React подставляет во внутреннее поле «текущий набор хуков», а сразу после — убирает. Пример ниже отрисовывается нормально и ломается на клике.
В боевой сборке это «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: считать внутри, а не решать снаружи, звать ли хук.
Ветку или строку списка — в отдельный компонент. Если двум веткам нужны разные хуки, значит это два разных компонента. Хук возвращается на верхний уровень, просто уровень теперь свой — и React смонтирует нужный компонент, а ненужный размонтирует вместе со всеми его ячейками.
В выводе видно главное: клик по второй строке перерисовал только её. Состояние переехало туда, где оно и живёт — в саму строку, — и хук снова вызывается ровно один раз за рендер.
Такой потребности почти никогда нет — есть желание сэкономить. Хук, который «не нужен» в этом рендере, стоит копейки: 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Где хук вызывать можно?
Хук в цикле
Каждая строка доски должна помнить, отмечена ли она. Сейчас состояние заводится в цикле — запустите пример и посмотрите, что произойдёт, когда список станет короче. Перепишите так, чтобы правило хуков не нарушалось.
Число вызовов useState равно длине массива, то есть зависит от данных: строк стало меньше — и рендер обрывается ошибкой. Собственное состояние строки — верный признак того, что строка должна быть компонентом: тогда хук снова вызывается ровно один раз на своём верхнем уровне.
key здесь не формальность: по ключу React решает, какой строке принадлежит какая ячейка состояния, когда строки добавляются и удаляются.
Условие вокруг эффекта
Подписка нужна только во включённом состоянии, поэтому useEffect завёрнут в if. Запустите пример, посмотрите на ошибку и почините компонент, не меняя его поведения: при enabled === false в консоль ничего писаться не должно.
Обёртка вокруг вызова меняет число хуков: при enabled === true их два, при false — один. Условие переезжает внутрь эффекта, а enabled уходит в зависимости — иначе эффект не узнает о переключении.
Пустой массив зависимостей тут был бы второй ошибкой: эффект прочитал бы enabled один раз и навсегда — это устаревшее значение из статьи «Зависимости и устаревшие значения».
Предсказать съехавшие ячейки
Перед вами мини-React из первого раздела и компонент с тремя вызовами, один из которых спрятан в условие. Скажите, что напечатает второй рендер, — и только потом запускайте.
Первый рендер занимает три ячейки: 0 = "Отчёт", 1 = "подробно", 2 = 320. Во втором рендере условный вызов пропадает, курсор идёт 0, 1 — и width получает ячейку 1, то есть строку «подробно».
Настоящий React в такой ситуации оборвал бы рендер: ячеек три, а вызовов пришло два. Наш мини-React проверки не делает и молча отдаёт съехавшие значения — ровно то, от чего правило и защищает.