Хук (hook) — функция React с именем на use, которая подключает к компоненту состояние, контекст, ссылки, эффекты или действия. Ниже — встроенные хуки React 19.3 по группам: сигнатура, когда нужен и частая ошибка, плюс короткий пример на TypeScript для каждого ключевого хука. Все примеры прошли tsc, Vitest и запуск в браузере.
После материала вы сможете
- найти сигнатуру встроенного хука React 19 и понять, какую задачу он решает
- выбрать между
useStateиuseReducer,useEffectиuseLayoutEffect,useTransitionиuseDeferredValue - распознать типичную ошибку хука по симптому и исправить её
Что знать заранее
Проверено на
React 19.3.0TypeScript 6.0.3, 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)
Сверено с документацией: 12 октября 2026 г.
Содержание
- Правила хуков и карта групп
- Состояние: useState и useReducer
- Эффекты: useEffect, useLayoutEffect и родственники
- Ссылки и контекст: useRef, useContext, use
- Производительность: useMemo, useCallback, useTransition, useDeferredValue
- Действия и формы: useActionState, useOptimistic, useFormStatus
- Прочие: useId, useSyncExternalStore, use(promise)
- Частые ошибки: симптом → причина
Правила хуков и карта групп
React различает хуки по порядку вызова, поэтому правила строгие: вызывайте хуки только на верхнем уровне компонента или своего хука — до любого раннего return, не внутри условий, циклов, вложенных функций и try/catch. Исключение — use: его можно вызывать в условиях и циклах.
Состояние
- Хуки
useState,useReducer- Зачем
- компонент «помнит» данные между отрисовками
Контекст
- Хуки
useContext,use- Зачем
- получить значение от далёкого родителя без пропсов
Ссылки
- Хуки
useRef,useImperativeHandle- Зачем
- хранить значение без перерисовки, доступ к DOM
Эффекты
- Хуки
useEffect,useLayoutEffect,useInsertionEffect,useEffectEvent- Зачем
- синхронизация с внешними системами
Производительность
- Хуки
useMemo,useCallback,useTransition,useDeferredValue- Зачем
- пропустить лишнюю работу, разделить срочные и фоновые обновления
Действия и формы
- Хуки
useActionState,useOptimistic,useFormStatus(изreact-dom)- Зачем
- состояние отправки, мгновенный отклик
Прочие
- Хуки
useId,useSyncExternalStore,useDebugValue- Зачем
- id для доступности, внешние хранилища, подписи в DevTools
import { useState } from "react";
// Неправильно: хук после раннего return — порядок вызовов меняется между рендерами
export function Price({ amount }: { amount: number | null }) {
if (amount === null) return null;
const [currency, setCurrency] = useState("KZT");
return (
<button onClick={() => setCurrency(currency === "KZT" ? "USD" : "KZT")}>
{amount} {currency}
</button>
);
}eslint-plugin-react-hooks 7.1 отмечает ошибку: «React Hook "useState" is called conditionally». Тест показал и последствие: выбрали USD, цена пропала и вернулась — снова KZT, состояние молча потерялось. Если хуков несколько, React бросает «Rendered more hooks than during the previous render» или «Rendered fewer hooks than expected».import { useState } from "react";
// Правильно: хуки — до любого раннего return, порядок вызовов всегда одинаковый
export function Price({ amount }: { amount: number | null }) {
const [currency, setCurrency] = useState("KZT");
if (amount === null) return null;
return (
<button onClick={() => setCurrency(currency === "KZT" ? "USD" : "KZT")}>
{amount} {currency}
</button>
);
}Состояние: useState и useReducer
useState
- Сигнатура
const [state, setState] = useState(initialState)- Когда
- одно значение, от которого зависит отрисовка: поле ввода, флаг, выбранный элемент
- Частая ошибка
- читать новое значение сразу после
setState— состояние это снимок, оно изменится к следующей отрисовке; мутировать объект вместо копии
useReducer
- Сигнатура
const [state, dispatch] = useReducer(reducer, initialArg, init?)- Когда
- много связанных полей и переходов: корзина, мастер формы, список задач
- Частая ошибка
- мутировать
stateв редьюсере или забыть...state— часть полей станетundefined
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
function addThree() {
// updater получает значение с учётом предыдущих вызовов в очереди
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
}
return <button onClick={addThree}>Счёт: {count}</button>;
}setCount(count + 1) три раза было бы +1: все три вызова видят один и тот же снимок.export type Todo = { id: string; text: string; done: boolean };
export type TodoAction =
| { type: "added"; id: string; text: string }
| { type: "toggled"; id: string }
| { type: "deleted"; id: string };
// чистая функция: (текущее состояние, действие) → новое состояние
export function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "added":
return [...todos, { id: action.id, text: action.text, done: false }];
case "toggled":
return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "deleted":
return todos.filter((t) => t.id !== action.id);
}
}Object.freeze) и не меняется. В компоненте: const [todos, dispatch] = useReducer(todosReducer, []) и dispatch({ type: "toggled", id }).- Дорогое начальное значение передавайте функцией:
useState(() => createRows()), а неuseState(createRows())— иначе оно считается на каждой отрисовке. setX(prev => …), когда новое значение зависит от старого.- «Too many re-renders» обычно значит
onClick={handleClick()}вместоonClick={handleClick}. - Подробно о снимках и обновлениях без мутаций — в уроке про
useState.
Эффекты: useEffect, useLayoutEffect и родственники
useEffect
- Сигнатура
useEffect(setup, dependencies?)- Когда
- синхронизация с внешней системой: подписка, таймер, сокет, виджет не на React
- Частая ошибка
- вычислять в эффекте то, что можно посчитать при рендере; пропускать зависимости; эффект меняет свою же зависимость — бесконечный цикл
useLayoutEffect
- Сигнатура
useLayoutEffect(setup, dependencies?)- Когда
- измерить раскладку до отрисовки кадра: позиция подсказки, высота блока
- Частая ошибка
- ставить вместо
useEffect«на всякий случай» — код блокирует отрисовку; на сервере не выполняется
useInsertionEffect
- Сигнатура
useInsertionEffect(setup, dependencies?)- Когда
- вставка стилей до срабатывания layout-эффектов — для авторов библиотек CSS-in-JS
- Частая ошибка
- использовать в прикладном коде
useEffectEvent
- Сигнатура
const onEvent = useEffectEvent(callback)- Когда
- логика внутри эффекта, которая читает свежие пропсы, но не должна перезапускать эффект
- Частая ошибка
- прятать через него зависимости; вызывать при рендере или передавать в другие компоненты
import { useEffect, useState } from "react";
export function Clock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id); // очистка: перед повтором и при размонтировании
}, []); // реактивных значений внутри нет — подписка один раз
return <time>{now.toLocaleTimeString("ru-RU")}</time>;
}import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
type Props = { targetTop: number; children: ReactNode };
export function Tooltip({ targetTop, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
// измеряем до того, как браузер нарисует кадр, — без мигания
setHeight(ref.current!.getBoundingClientRect().height);
}, []);
// сверху не помещается — показываем под целью
const top = height > targetTop ? targetTop + 24 : targetTop - height;
return (
<div ref={ref} role="tooltip" style={{ position: "absolute", top }}>
{children}
</div>
);
}top: 50px, при 150px — под цель, на 124px. В Edge: top = 100 − измеренная высота (82px).Ссылки и контекст: useRef, useContext, use
useRef
- Сигнатура
const ref = useRef(initialValue)- Когда
- DOM-узел, id таймера, предыдущее значение — всё, что не нужно для отрисовки
- Частая ошибка
- читать или менять
ref.currentво время рендера; ждать перерисовки после изменения — её не будет
useImperativeHandle
- Сигнатура
useImperativeHandle(ref, createHandle, dependencies?)- Когда
- отдать родителю ограниченный набор методов (
focus,scrollTo) - Частая ошибка
- отдавать методами то, что выражается пропсом:
{ open, close }вместоisOpenу модального окна
useContext
- Сигнатура
const value = useContext(SomeContext)- Когда
- тема, язык, текущий пользователь — данные для многих уровней дерева
- Частая ошибка
- ставить провайдер в том же компоненте или ниже: хук видит только провайдеры выше; новый объект
valueна каждую отрисовку перерисовывает всех потребителей
use (контекст)
- Сигнатура
const value = use(SomeContext)- Когда
- то же, но можно после условия или раннего
return - Частая ошибка
- в Server Components чтение контекста через
useне поддерживается
import { useRef } from "react";
export function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<input ref={inputRef} aria-label="Поиск" />
<button onClick={() => inputRef.current?.focus()}>Искать</button>
</>
);
}import { createContext, use, useContext, useState } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<Theme>("light"); // значение, если провайдера выше нет
export function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Тема: {theme}</button>;
}
function Hint({ show }: { show: boolean }) {
if (!show) return null;
const theme = use(ThemeContext); // use() можно вызвать после условия
return <p>Подсказка для темы {theme}</p>;
}
export function App() {
const [theme, setTheme] = useState<Theme>("dark");
return (
// React 19: сам контекст работает как провайдер
<ThemeContext value={theme}>
<ThemedButton />
<Hint show />
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>Сменить</button>
</ThemeContext>
);
}use, после клика — «Тема: light»; ThemedButton без провайдера получает значение по умолчанию light. В React 19 ref можно передавать обычным пропсом, а контекст — писать как <ThemeContext value={…}>.Производительность: useMemo, useCallback, useTransition, useDeferredValue
useMemo
- Сигнатура
const value = useMemo(calculateValue, dependencies)- Когда
- дорогое вычисление; стабильная ссылка на объект для
memoили зависимостей - Частая ошибка
useMemo(() => { a: 1 }, [])вернётundefined— нужны скобки() => ({ a: 1 }); оборачивать всё подряд
useCallback
- Сигнатура
const fn = useCallback(fn, dependencies)- Когда
- функция уходит в
memo-компонент или в зависимости эффекта - Частая ошибка
- без
memoу получателя выигрыша нет; без массива зависимостей функция новая на каждой отрисовке
useTransition
- Сигнатура
const [isPending, startTransition] = useTransition()- Когда
- неблокирующее обновление и Action: смена вкладки, сохранение с индикатором
- Частая ошибка
- управлять через переход значением поля ввода; обновлять состояние после
awaitбез новогоstartTransition
useDeferredValue
- Сигнатура
const deferred = useDeferredValue(value, initialValue?)- Когда
- тяжёлая часть интерфейса отстаёт от ввода, а поле остаётся отзывчивым
- Частая ошибка
- передавать объект, созданный при рендере, — он всегда «новый»; ждать, что он сократит запросы к серверу
import { memo, useCallback, useMemo, useState } from "react";
type Product = { id: number; name: string };
type RowProps = { product: Product; onSelect: (id: number) => void };
const Row = memo(function Row({ product, onSelect }: RowProps) {
return (
<li>
<button onClick={() => onSelect(product.id)}>{product.name}</button>
</li>
);
});
export function ProductList({ products }: { products: Product[] }) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<number | null>(null);
// пересчёт только при смене products или query
const visible = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query],
);
// та же функция между рендерами — memo(Row) не перерисовывает строки
const handleSelect = useCallback((id: number) => setSelected(id), []);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Фильтр" />
<p>Выбран: {selected ?? "—"}</p>
<ul>
{visible.map((p) => (
<Row key={p.id} product={p} onSelect={handleSelect} />
))}
</ul>
</>
);
}filter не вызывается второй раз, а строки не перерисовываются (геттер name не читается); ввод в фильтр пересчитывает список.import { useState, useTransition } from "react";
import { saveQuantity } from "./api";
export function Quantity() {
const [qty, setQty] = useState(1);
const [isPending, startTransition] = useTransition();
function add() {
startTransition(async () => {
const saved = await saveQuantity(qty + 1);
// обновление после await снова оборачиваем в startTransition
startTransition(() => setQty(saved));
});
}
return (
<>
<button onClick={add} disabled={isPending}>+1</button>
<output>{isPending ? "Сохраняем…" : qty}</output>
</>
);
}disabled, после ответа — 2. В браузере с настоящей задержкой 300 мс — то же.import { memo, useDeferredValue, useState } from "react";
const Results = memo(function Results({ items, query }: { items: string[]; query: string }) {
const found = items.filter((item) => item.includes(query));
return <ul>{found.map((item) => <li key={item}>{item}</li>)}</ul>;
});
export function Search({ items }: { items: string[] }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query); // список догоняет ввод в фоне
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Поиск" />
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<Results items={items} query={deferredQuery} />
</div>
</>
);
}Действия и формы: useActionState, useOptimistic, useFormStatus
useActionState
- Сигнатура
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?)- Когда
- результат отправки формы или действия: ошибки, ответ сервера, флаг ожидания
- Частая ошибка
- вызывать
dispatchActionвне Action (не вactionформы и не вstartTransition) — ошибка в режиме разработки; ждать параллельности — вызовы идут по очереди
useOptimistic
- Сигнатура
const [optimistic, setOptimistic] = useOptimistic(value, reducer?)- Когда
- показать результат сразу, пока запрос в пути: лайк, сообщение, товар в корзине
- Частая ошибка
- вызывать сеттер вне Action — предупреждение, и оптимистичное значение мелькнёт и пропадёт
useFormStatus
- Сигнатура
const { pending, data, method, action } = useFormStatus()- Когда
- кнопка или поле внутри формы знает, что форма отправляется
- Частая ошибка
- вызывать в том же компоненте, где
<form>: хук видит только родительскую форму
import { useActionState } from "react";
import { subscribe } from "./api";
type State = { ok: boolean; message: string } | null;
async function subscribeAction(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) return { ok: false, message: "Введите email" };
try {
await subscribe(email);
return { ok: true, message: "Вы подписаны" };
} catch {
return { ok: false, message: "Не получилось, попробуйте позже" };
}
}
export function SubscribeForm() {
const [state, formAction, isPending] = useActionState(subscribeAction, null);
return (
<form action={formAction}>
<input name="email" aria-label="Email" />
<button disabled={isPending}>{isPending ? "Отправляем…" : "Подписаться"}</button>
{state && <p role="status">{state.message}</p>}
</form>
);
}disabled, затем «Вы подписаны»; отклонённый запрос — сообщение об ошибке. Сетевой вызов заменён заглушкой.import { startTransition, useOptimistic, useState } from "react";
import { sendMessage, type Message } from "./api";
type Shown = Message & { sending?: boolean };
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [shown, addShown] = useOptimistic<Shown[], string>(messages, (state, text) => [
...state,
{ id: "pending", text, sending: true },
]);
function send(text: string) {
startTransition(async () => {
addShown(text); // сообщение видно сразу, пока идёт запрос
const saved = await sendMessage(text);
startTransition(() => setMessages((m) => [...m, saved]));
});
}
return (
<>
<button onClick={() => send("Здравствуйте!")}>Отправить</button>
<ul>
{shown.map((m) => (
<li key={m.id}>
{m.text} {m.sending && "(отправка…)"}
</li>
))}
</ul>
</>
);
}Прочие: useId, useSyncExternalStore, use(promise)
useId
- Сигнатура
const id = useId()- Когда
- связать
label,aria-describedbyи поле; одинаковый id при серверной и клиентской отрисовке - Частая ошибка
- делать из него
keyв списке или ключ кэша — их берут из данных
useSyncExternalStore
- Сигнатура
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)- Когда
- подписка на внешнее хранилище или API браузера: сеть, размер окна, сторонний store
- Частая ошибка
getSnapshotкаждый раз возвращает новый объект — ошибка «The result of getSnapshot should be cached»;subscribeвнутри компонента — переподписка на каждой отрисовке
use (промис)
- Сигнатура
const value = use(promise)- Когда
- прочитать результат промиса внутри
<Suspense> - Частая ошибка
- создавать промис во время рендера — нужен кэшированный промис; оборачивать
useвtry/catch— ошибки ловит Error Boundary
useDebugValue
- Сигнатура
useDebugValue(value, format?)- Когда
- подпись своего хука в React DevTools
- Частая ошибка
- считать в нём дорогое значение без
format
import { useId } from "react";
export function PasswordField() {
const id = useId(); // свой у каждого экземпляра, совпадает на сервере и клиенте
return (
<>
<label htmlFor={`${id}-input`}>Пароль</label>
<input id={`${id}-input`} type="password" aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Не меньше 12 символов</p>
</>
);
}getAllByLabelText("Пароль") находит оба, aria-describedby указывает на подсказку.import { useSyncExternalStore } from "react";
// объявлена вне компонента — React не переподписывается на каждом рендере
function subscribe(callback: () => void) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // снимок на клиенте
() => true, // снимок при серверном рендеринге
);
}offline переключает хук на false. В Edge режим offline через Playwright — то же.import { Suspense, use } from "react";
import type { User } from "./api";
function UserName({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // пока промис не выполнен, виден fallback
return <p>{user.name}</p>;
}
// промис создают вне рендера: в загрузчике, обработчике или Server Component
export function Profile({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<p>Загрузка…</p>}>
<UserName userPromise={userPromise} />
</Suspense>
);
}Частые ошибки: симптом → причина
после setState в консоли старое значение
- Причина
- состояние — снимок текущей отрисовки
- Что сделать
- использовать значение, которое передали в сеттер, или
setX(prev => …)
экран не обновился после изменения объекта
- Причина
- объект изменён на месте, ссылка та же
- Что сделать
- создать копию:
{ ...obj, field },map,filter
эффект запускается бесконечно
- Причина
- эффект меняет состояние, от которого сам зависит, или зависит от объекта, созданного при рендере
- Что сделать
- убрать вычисление из эффекта, вынести объект внутрь эффекта или в
useMemo
эффект сработал дважды в разработке
- Причина
StrictModeпроверяет очистку- Что сделать
- дописать функцию очистки, а не отключать проверку
memo не помогает, дочерний компонент перерисовывается
- Причина
- в пропсах новая функция или объект
- Что сделать
useCallback/useMemoдля этих пропсов
поле ввода «тормозит» или не печатает
- Причина
- значение поля обновляется внутри
startTransition - Что сделать
- поле — обычный
setState, тяжёлую часть — черезuseDeferredValue
состояние сбросилось или ошибка о числе хуков
- Причина
- хук вызван после условия или раннего
return - Что сделать
- поднять все хуки наверх, включить
eslint-plugin-react-hooks
«A component was suspended by an uncached promise»
- Причина
- промис для
useсоздаётся при каждом рендере - Что сделать
- создавать промис в загрузчике, обработчике или кэшировать
Глубже про состояние — в уроке «React: состояние и useState», про эффекты, гонки запросов и StrictMode — в материале про useEffect. Хуки форм на практике разобраны в уроке о формах и валидации в React.
Как проверено3 · пройденосверено 12 октября 2026 г.
- запущено, работает
16 примеров (Counter, todos, TodoList, Clock, Tooltip, SearchBox, theme, ProductList, Quantity, Search, SubscribeForm, Chat, Profile, useOnlineStatus, PasswordField, Price) — RU и EN
временный Vite-проект, React 19.3.0 (npm view react version): tsc --noEmit на TypeScript 6.0.3 и 7.0.2 — 0 ошибок; vite build 8.3.4 — успешно; Vitest 5.0.3 + jsdom 30.1.2 + @testing-library/react 16.3.3 — 44 теста (22 RU + 22 EN) прошли; eslint-plugin-react-hooks 7.1.1 (recommended) — 0 замечаний
- запущено, работает
PriceWrong.tsx (нарушение правил хуков)
eslint: react-hooks/rules-of-hooks «React Hook "useState" is called conditionally»; Vitest: состояние USD теряется (снова KZT) после amount null → 1000; вариант с двумя хуками бросает «Rendered more hooks than during the previous render» и «Rendered fewer hooks than expected»
- запущено, работает
все примеры в браузере
browser-check.mjs: dev-сервер Vite в StrictMode + Microsoft Edge 155 через Playwright 1.64, RU и EN: счётчик +3, useTransition с реальной задержкой 300 мс, useOptimistic pending → сохранено, use(promise), useActionState, useLayoutEffect top = 100 − высота (82px), часы идут, useSyncExternalStore online/offline через setOffline; 0 ошибок и предупреждений в консоли
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.