Хуки вызывают только на верхнем уровне функционального компонента или кастомного хука, никогда внутри условий, циклов, вложенных функций или после раннего return. React узнаёт каждый хук по порядку вызова, поэтому этот порядок должен совпадать на каждом рендере.
Только верхний уровень: if вокруг useState сдвигает все следующие хуки на одну ячейку на том рендере, где условие меняется, и компонент читает чужое состояние.
Только из функций React: у хука, вызванного из обычной функции-хелпера или класса, нет компонента, которому принадлежит его состояние.
Кастомные хуки тоже считаются: функция, имя которой начинается с use, может вызывать хуки, и внутри неё действуют те же правила.
**Исключение use**: API use из React 19 можно вызывать внутри if и циклов, но всё ещё не внутри try/catch.
Контроль: eslint-plugin-react-hooks (rules-of-hooks) ловит нарушения на этапе линтинга, а в рантайме React бросает ошибки вроде "Rendered more hooks than during the previous render".
ReactMiddle
Хуки подробно
Правила хуков, кастомные хуки, useReducer, useContext, useMemo и useCallback, useId, useSyncExternalStore, useTransition, use
Закрыто вопросов
0 / 19
- 0Повторить
- 9Часто спрашивают
Вопросы
Кастомный хук представляет собой функцию, имя которой начинается с use и которая вызывает другие хуки. Он переиспользует логику с состоянием между компонентами, а не само состояние: каждый компонент, который его вызывает, получает свою копию этого состояния.
Выносить стоит, когда два компонента повторяют одну и ту же связку useState + useEffect или когда компонент смешивает UI с блоком логики, у которого есть понятное имя (useDebouncedValue, useOnlineStatus, useMediaQuery).
**Префикс use важен: он говорит линтеру и React Compiler проверять правила хуков внутри функции.
Не стоит выносить в хук функцию, которая не вызывает хуков: обычная функция проще, и её можно вызывать где угодно, даже по условию.
Называйте хук по тому, что он делает**, а не по жизненному циклу: useChatConnection(roomId), а не useMountEffect.
useContext позволяет любому компоненту прочитать значение ближайшего провайдера выше по дереву без передачи пропсов через каждый промежуточный компонент (prop drilling). Контекст создают через createContext(defaultValue), оборачивают поддерево провайдером и ниже вызывают useContext(MyContext).
React 19: провайдером служит сам объект контекста, <ThemeContext value={theme}>; <ThemeContext.Provider> пока работает, но постепенно уходит.
Побеждает ближайший провайдер: вложенный провайдер переопределяет значение для своего поддерева.
Значение по умолчанию используется, только когда провайдера выше нет совсем, а не когда провайдер передал undefined.
Хорошо подходит для темы, локали, текущего пользователя, состояния фичи и её dispatch. Данные, которые меняются много раз в секунду, подходят плохо: на каждое изменение ререндерятся все потребители.
useReducer нужен, когда следующее состояние зависит от предыдущего через несколько видов обновлений или когда несколько полей меняются вместе: все переходы живут в одной чистой функции reducer(state, action), а не разбросаны по обработчикам событий.
Сигналы для перехода: много вызовов setX в одном обработчике, поля, которые должны оставаться согласованными (status, data, error), или одна и та же логика обновления, скопированная в несколько обработчиков.
Легко тестировать: редьюсер представляет собой обычную функцию, поэтому переходы проверяют без рендера.
**Стабильный dispatch: его идентичность не меняется, поэтому его безопасно передавать вниз или класть в контекст без мемоизации.
Чистота**: редьюсер не должен мутировать состояние, ходить в сеть или читать время; в StrictMode React в разработке вызывает его дважды, чтобы это проявилось.
Для одного-двух независимых значений useState короче и понятнее.
React.memo оборачивает компонент и пропускает его ререндер, если пропсы равны предыдущим. useMemo вызывают как хук внутри компонента: он кэширует вычисленное значение между рендерами, пока не изменится одна из зависимостей.
Уровень: memo применяется к компоненту целиком, один раз, там, где компонент объявлен; useMemo применяется к одному значению внутри рендера.
Сравнение: memo сравнивает каждый проп через Object.is; useMemo так же сравнивает элементы массива зависимостей.
Они работают в паре: memo-потомок пропускает рендер, только если каждый проп сохранил ссылку, и именно useMemo (или useCallback для функции) позволяет родителю держать проп-объект или массив стабильным.
React Compiler делает аналог обоих автоматически, поэтому в проекте с компилятором вручную они нужны редко.
По порядку вызовов. Каждый экземпляр компонента (его fiber) хранит список ячеек хуков; на каждом рендере React идёт по этому списку синхронно с вызовами хуков, поэтому первый useState читает ячейку 1, второй читает ячейку 2 и так далее.
Первый рендер: каждый вызов хука добавляет новую ячейку с его состоянием, очередью отложенных обновлений или эффектом.
Следующие рендеры: каждый вызов берёт следующую ячейку; на имена переменных и аргументы React не смотрит.
Отсюда правила: если условие пропустит хук, все следующие вызовы прочитают не ту ячейку. Когда число хуков отличается, React бросает "Rendered more hooks than during the previous render", а когда отличаются только их виды, предупреждает об изменении порядка хуков.
Кастомные хуки прозрачны: их внутренние хуки становятся просто дополнительными ячейками в списке вызывающего компонента, поэтому два компонента с одним кастомным хуком никогда не делят его состояние.
Состояние живёт вместе с экземпляром: стоит размонтировать компонент или поменять его key, и список со всем состоянием выбрасывается.
useMemo(fn, deps) кэширует результат вызова fn; useCallback(fn, deps) кэширует саму функцию и равносилен useMemo(() => fn, deps). Оба возвращают прежнее значение, пока зависимость не изменится по Object.is.
Стоит использовать: для заметно медленного вычисления (сортировка или фильтрация тысяч строк); для значения или колбэка, которые передаются memo-потомку, ведь он пропускает ререндер, только если пропсы равны по ссылке; для значения, которое стоит в массиве зависимостей другого хука.
Не стоит использовать: для дешёвых выражений или колбэков, которые передаются обычным DOM-элементам; сравнение и замыкание стоят дороже, чем экономят.
Пропущенная зависимость возвращает устаревшее значение или колбэк, который видит старое состояние; доверяйте правилу линтера exhaustive-deps.
Кэш, а не гарантия: React может выбросить закэшированное значение (например, при hot reload в разработке), поэтому код должен оставаться корректным и без него.
React Compiler 1.0 мемоизирует компоненты и значения автоматически, поэтому в проекте с компилятором новому коду эти хуки нужны редко; они остаются запасным выходом, например чтобы сделать зависимость эффекта стабильной.
Когда провайдер получает новое value (сравнение через Object.is), React ререндерит каждый компонент, который читает этот контекст, даже если он использует одно поле, и memo на потребителе это не останавливает. Инлайн-значение провайдера на каждом рендере родителя будет новым объектом.
Мемоизируйте значение: оберните объект в useMemo (или положитесь на React Compiler), чтобы он менялся, только когда меняется содержимое.
Разделите контексты: редко меняющиеся части (dispatch, API-клиент) и часто меняющееся состояние положите в разные контексты; компоненты, которые только диспатчат, тогда не ререндерятся при изменении состояния.
Разделите по доменам: ThemeContext, AuthContext, CartContext вместо одного AppContext.
Опустите состояние вниз: быстро меняющееся состояние (текст инпута, hover) держите локально, а не в контексте.
Нужны селекторы? У контекста их нет. Стор на useSyncExternalStore (Zustand, Redux) ререндерит только те компоненты, чей выбранный срез изменился.
Ранний return стоит выше useState и useEffect, поэтому рендер для гостя не вызывает после него ни одного хука, а рендер для вошедшего пользователя вызывает ещё два. React сопоставляет хуки по порядку вызовов и бросает ошибку, когда их число между рендерами меняется.
На первом рендере user равен null, компонент выходит до хуков, и React запоминает, что после useContext их ноль.
После входа тот же экземпляр рендерится с пользователем, доходит до useState, и React находит больше хуков, чем сохранил.
Исправление: сначала безусловно вызовите все хуки и ставьте ранний return после них; сам эффект проверяет user.
Другой вариант: вынести часть для вошедшего пользователя в отдельный компонент (<ProfileDetails user={user} />), который монтируется со своими хуками.
eslint-plugin-react-hooks сообщает ровно об этом паттерне: "React Hook is called conditionally".
Выведется 0. useCallback с пустым массивом зависимостей хранит функцию из первого рендера, а та замкнула count таким, каким он был тогда, поэтому более поздних значений она не видит (устаревшее замыкание, stale closure).
У каждого рендера своя константа count; закэшированный log принадлежит первому рендеру.
Исправление: укажите зависимость, useCallback(() => console.log(count), [count]), как и потребует exhaustive-deps.
Если колбэк должен сохранять стабильную идентичность и при этом читать последнее значение, храните значение в ref, а внутри эффекта используйте useEffectEvent (стабилен с React 19.2).
С React Compiler useCallback здесь можно убрать совсем: компилятор сам отслеживает count как зависимость.
Состояние держат в useReducer внутри компонента-провайдера, state и dispatch отдают через два контекста, а чтение оборачивают в небольшие кастомные хуки. Любой компонент в поддереве тогда читает состояние или диспатчит экшены без prop drilling.
Кастомные хуки как API: useCart() и useCartDispatch() прячут контексты и бросают понятную ошибку, если их вызвали вне провайдера.
Два контекста: dispatch не меняется, поэтому компоненты, которые только отправляют экшены, не ререндерятся при изменении состояния.
Где перестаёт масштабироваться: каждый читатель контекста состояния ререндерится на любое изменение, ведь селекторов у контекста нет; большое, часто обновляемое состояние (большая таблица, живые данные) начинает тормозить.
Нет готовых инструментов: ни time travel в devtools, ни middleware, ни персистентности; всё это придётся писать самому.
Тогда берите Zustand, Redux Toolkit или похожий стор, который подписывает компоненты на срезы; серверным данным место в TanStack Query, а не в редьюсере.
useId() возвращает уникальную строку-идентификатор, которая стабильна всё время жизни компонента и совпадает на сервере и на клиенте, поэтому ею безопасно связывать <label> с <input> или aria-describedby с подсказкой в HTML, отрендеренном на сервере.
**Math.random() даёт разные id на сервере и на клиенте, а это ведёт к ошибке гидратации (hydration mismatch), и к тому же новый id на каждом рендере.
Счётчик в модуле** зависит от порядка рендера; сервер и клиент или два параллельных запроса на сервере могут посчитать по-разному.
useId выводит id из позиции компонента в дереве, а она одинакова в обеих средах.
Один вызов, много id: добавляйте суффиксы, ${id}-email, ${id}-hint.
Не для ключей списков: ключи берут из данных. Нескольким React-корням на одной странице нужны разные опции identifierPrefix.
Оба помечают обновление как несрочное, чтобы React отрендерил его в фоне и прервал ради срочного ввода вроде набора текста. useTransition оборачивает обновление состояния, которым вы управляете; useDeferredValue даёт отстающую копию значения, которое вы только получаете.
**useTransition**: возвращает [isPending, startTransition]. Оберните setTab(next) в startTransition, и старая вкладка остаётся на экране и отзывчивой, пока рендерится новая; по isPending показывают спиннер или приглушённое состояние.
React 19: startTransition принимает async-функцию (Action), и isPending остаётся true, пока она не завершится.
**useDeferredValue(value)**: сначала ререндерит со старым значением, потом в фоне с новым. Подходит, когда медленная часть получает значение пропсом, например тяжёлый список, отфильтрованный по query.
Никогда для контролируемых инпутов: собственное состояние инпута должно обновляться срочно, иначе ввод лагает; откладывайте дорогого потребителя.
С Suspense: внутри перехода React продолжает показывать старый контент, а не уходит в fallback-лоадер.
Они делают медленный рендер прерываемым, а не быстрее; тяжёлый потомок должен быть обёрнут в memo (или скомпилирован), чтобы был выигрыш.
use(resource) читает промис или контекст во время рендера. С промисом компонент приостанавливается (suspend), пока тот не выполнится (ближайший <Suspense> показывает fallback, а отклонённый промис уходит в ближайший error boundary); с контекстом он работает как useContext.
Можно вызывать по условию: в отличие от других хуков, use можно ставить внутри if и циклов, поэтому контекст можно читать только в той ветке, где он нужен. Внутри try/catch вызывать его по-прежнему нельзя.
Промис должен быть стабильным: промис, созданный во время рендера, на каждом рендере новый, поэтому компонент снова приостанавливается на каждой попытке, а React предупреждает о некэшированном промисе. Создавайте его в Server Component и передавайте вниз или берите из кэша (лоадер фреймворка, библиотека данных).
Типичный сценарий: Server Component запускает запрос без await и передаёт промис в Client Component, который вызывает use(promise) внутри границы Suspense.
Сам по себе это не инструмент загрузки данных: у него нет кэширования, повторных попыток и инвалидации.
Перезапускайте таймер на каждое изменение и действуйте, только когда он сработал после паузы. Чаще всего такой хук возвращает отложенное значение: setTimeout в useEffect, который очищается в cleanup эффекта. Для работы, которая не является состоянием, например запроса или автосохранения, откладывают колбэк и хранят его таймер в ref.
Отложенное значение: useDebouncedValue(query, 300) возвращает копию query, которая обновляется через 300 мс после того, как пользователь перестал печатать; каждое нажатие перезапускает эффект, а его cleanup отменяет предыдущий таймер. Результат передают в ключ запроса.
Отложенный колбэк: возвращаемая функция остаётся одной и той же между рендерами, поэтому таймер живёт в useRef, а не в локальной переменной.
Классический баг: const search = debounce(onSearch, 300) (lodash) в теле компонента создаёт новую debounce-функцию со своим таймером на каждом рендере, поэтому запрос всё равно уходит на каждое нажатие.
Последний колбэк: храните fn в ref, который обновляется после каждого рендера, иначе стабильная функция будет вызывать fn из первого рендера с устаревшими пропсами.
Очистка: отменяйте отложенный таймер при размонтировании, иначе запрос уйдёт уже после того, как пользователь покинул страницу.
Считайте localStorage внешним хранилищем и читайте его через useSyncExternalStore: подпишитесь на событие storage, чтобы получать записи из других вкладок, отправляйте своё событие для записей в этой вкладке и возвращайте null из серверного снимка, чтобы серверный рендеринг никогда не обращался к window.
Синхронизация вкладок: браузер отправляет storage во все остальные вкладки того же origin, когда ключ меняется, но никогда во вкладку, которая записала значение; собственное событие синхронизирует два компонента в одной вкладке.
SSR и гидратация: серверный снимок рендерит начальное значение на сервере и во время гидратации, затем React перерендеривает компонент с сохранённым значением, без hydration mismatch.
Стабильный снимок: возвращайте сырую строку, она равна по значению, пока данные не изменились; JSON.parse внутри getSnapshot возвращает новый объект на каждый вызов и зацикливает рендер, поэтому парсить нужно в useMemo.
Повреждённые данные: JSON.parse стоит в try/catch, потому что значение от старой версии приложения или изменённое вручную не должно ронять страницу; setItem тоже бросает исключение, если квота заполнена или хранилище заблокировано.
**Почему не useState + useEffect**: ленивый инициализатор, читающий localStorage, падает на сервере («localStorage is not defined»), а копия в состоянии не видит записей из других вкладок и других компонентов.
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) подписывает компонент на стор, который живёт вне React (API браузера, стор на уровне модуля), и читает его так, что это безопасно при конкурентном рендеринге. Именно его внутри используют Zustand, Redux и похожие библиотеки.
Почему не useState + useEffect
Tearing (разрыв): во время прерываемого рендера стор может измениться между рендером двух компонентов, и на экране окажутся две разные версии одних и тех же данных. useSyncExternalStore замечает изменившийся снимок и синхронно перерендеривает, так что всё остаётся согласованным.
Потерянные обновления: изменение стора между рендером и подпиской в эффекте теряется, если не перечитать его вручную.
SSR: getServerSnapshot даёт значение для серверного рендера и гидратации.
Контракт
subscribe(callback) регистрирует слушателя и возвращает функцию отписки; объявляйте её вне компонента, иначе React переподписывается на каждом рендере.
getSnapshot() должна возвращать одно и то же значение (по Object.is), пока стор не изменился; новый объект на каждый вызов приводит к бесконечному циклу.
Обновления из внешнего стора синхронны; пометить их как переходы (transitions) нельзя.
getSnapshot создаёт новый объект на каждый вызов, поэтому React при каждой проверке видит другой снимок, решает, что стор изменился, ререндерит и снова проверяет, и так бесконечно. Снимок должен оставаться той же ссылкой, пока стор на самом деле не изменится.
React вызывает getSnapshot на каждом рендере и ещё раз перед коммитом, чтобы поймать tearing; результаты он сравнивает через Object.is.
Исправление 1: возвращайте значение, которое стор уже хранит (cartStore.getState() отдаёт один и тот же объект состояния, пока обновление его не заменит), а производные данные считайте в рендере.
Исправление 2: выбирайте примитив, например () => cartStore.getState().items.length, он равен по значению.
Исправление 3: если нужен производный объект, мемоизируйте его относительно исходной ссылки; именно это делают библиотеки селекторов (reselect, useShallow в Zustand).
subscribe объявлена один раз в модуле, и это уже правильно; инлайн-subscribe переподписывался бы на каждом рендере.
Решите, за что отвечает хук, возвращайте минимально полезный набор в стабильной форме и держите всё, что он возвращает, стабильным по ссылке, пока не изменился смысл. Хороший хук читается как встроенный: одна понятная задача, предсказуемые входы, никаких скрытых эффектов.
Форма входов и выходов
Возвращайте кортеж ([value, setValue]) для пары в духе useState, которую вызывающий переименует; возвращайте объект, когда полей три и больше или вызывающий берёт только часть.
Объект опций для всего, что больше одного-двух аргументов: useFetch(url, { enabled, retry }) остаётся читаемым и расширяется, не ломая вызывающих.
Принимайте значения, а не сеттеры вызывающего, и оставьте вызывающему решать, что делать с результатом.
Стабильность
Возвращаемые функции должны сохранять идентичность (useCallback, dispatch из useReducer или React Compiler), чтобы вызывающий мог передавать их memo-потомкам и в массивы зависимостей.
Не создавайте новый объект на каждом рендере, если ничего не изменилось; мемоизируйте возвращаемый объект или возвращайте примитивы.
Колбэк, который передал вызывающий (onChange), не должен перезапускать подписки на каждом рендере: читайте последний из ref или через useEffectEvent.
Границы
Не прячьте загрузку данных, которой нужен кэш, в самописный хук; лучше обернуть TanStack Query.
Держите хук без UI: хук, который возвращает JSX, на деле переодетый компонент.