Хуки и паттерны React: useState, useEffect, мемоизация, композиция
В предыдущей статье мы разобрали, как React превращает JSX в дерево fiber и как реконсиляция решает, что менять в DOM. Там компонент был чистой функцией пропсов. Реальный интерфейс так не живёт: он помнит введённый текст, подписывается на WebSocket, измеряет размер тултипа, отменяет запросы. Всё это — состояние и побочные эффекты, и именно за них отвечают хуки.
Хуки выглядят обманчиво просто: четыре строчки в компоненте — и работает. Проблемы начинаются на третьем месяце проекта: бесконечные циклы рендеров, «состояние отстаёт на один шаг», интерфейс, который перерисовывается 40 раз на один ввод символа. Почти все эти баги растут из одного корня — из неверной ментальной модели. Разберём модель до конца, а потом соберём на ней практические паттерны.
Откуда взялись хуки
До 2019 года состояние жило в классах, а логика переиспользовалась через миксины (React.createClass), потом через HOC (withRouter(withTheme(withData(Component)))) и render props. Все три подхода решали задачу «добавить поведение компоненту», и все три платили одним и тем же: обёртки множились, дерево в DevTools превращалось в лестницу из десяти анонимных компонентов, а имена пропсов конфликтовали — два HOC подряд могли передать разные data. Плюс классы плохо минифицировались, ломали tree-shaking и запутывали this.
Хуки в React 16.8 ответили иначе: не оборачивать компонент, а дать функции доступ к состоянию, которое живёт снаружи её вызова. Переиспользование стало композицией вызовов внутри одной функции, без единого дополнительного узла в дереве. Мотивацию авторы подробно расписали в Hooks RFC — этот текст стоит прочитать целиком, он объясняет не «как», а «зачем».
Ментальная модель: рендер — это снимок
Главная идея, из которой выводится всё остальное: каждый рендер — это отдельный вызов функции со своими локальными константами. props и результат useState внутри одного вызова заморожены навсегда. Функция не «обновляется» — React просто вызывает её заново и получает другой набор значений.
function ProfilePage({ userId }: { userId: string }) {
const [name, setName] = useState('Аня');
function handleClick() {
// Через 3 секунды покажет ТО имя, которое было в момент клика,
// даже если пользователь уже переименовался — замыкание захватило снимок.
setTimeout(() => alert(`Отправлено от имени ${name}`), 3000);
}
return <button onClick={handleClick}>Отправить</button>;
}
Это не баг, а свойство замыканий JavaScript. Дэн Абрамов разбирает его в классическом «A Complete Guide to useEffect»: у каждого рендера свои пропсы, своё состояние, свои обработчики и свои эффекты. Как только вы перестаёте думать «переменная изменилась» и начинаете думать «пришёл новый снимок», половина загадок исчезает.
Второе следствие: setState не меняет переменную, а ставит в очередь новый рендер. Внутри текущего вызова функции значение останется прежним до самого конца.
const [count, setCount] = useState(0);
function onClick() {
setCount(count + 1);
setCount(count + 1);
console.log(count); // 0 — и после клика count станет 1, а не 2
}
Оба вызова читают count === 0 из снимка. Лечится функциональной формой — она получает не снимок, а актуальное значение из очереди:
setCount(c => c + 1);
setCount(c => c + 1); // теперь 2
Правило простое: если новое состояние вычисляется из старого — всегда функциональная форма. Она же спасает в асинхронных колбэках, где снимок давно устарел.
Как React помнит состояние: слоты в fiber
Функция вызывается заново, локальные переменные умирают — где же живёт состояние? В узле fiber, к которому привязан компонент. Каждый вызов хука на первом рендере создаёт объект-хук и цепляет его в односвязный список fiber.memoizedState. На последующих рендерах React идёт по этому списку по порядку вызовов: первый вызванный хук получает первый узел, второй — второй.
Отсюда — единственное настоящее ограничение хуков: вызывать их можно только на верхнем уровне компонента или другого хука, всегда в одинаковом порядке и одинаковом количестве. Никаких if, циклов, try/catch, ранних return перед хуками. Почему выбрали именно порядок вызова, а не имена — Абрамов объяснил в «Why Do React Hooks Rely on Call Order?»: именованные ключи ломали бы композицию кастомных хуков (два вызова одного хука конфликтовали бы за ключ).
Проверять это руками не нужно — ставьте линтер и не спорьте с ним:
npm i -D eslint-plugin-react-hooks
// eslint.config.js — плоский конфиг ESLint 9
import reactHooks from 'eslint-plugin-react-hooks';
export default [{
files: ['**/*.{ts,tsx}'],
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error', // порядок вызовов — только error
'react-hooks/exhaustive-deps': 'warn', // зависимости эффектов
},
}];
Карта хуков
Хуков в React 19 около двадцати, но в ежедневной работе живут семь. Полезно держать в голове их роли, а не алфавитный список.
Официальный справочник с сигнатурами и предупреждениями — react.dev/reference/react. Ниже — то, чего в справочнике нет: цена, ловушки и практика.
useState вглубь
Ленивая инициализация. Аргумент useState(expr) вычисляется на каждом рендере, даже если результат отбрасывается. Если он дорогой — передавайте функцию:
// Плохо: parse выполняется на каждом рендере, результат нужен только на первом
const [state, setState] = useState(JSON.parse(localStorage.getItem('draft') ?? '{}'));
// Хорошо: функция вызовется ровно один раз, при монтировании
const [state, setState] = useState(() => JSON.parse(localStorage.getItem('draft') ?? '{}'));
Батчинг. Начиная с React 18 все обновления батчатся автоматически — и в обработчиках событий, и в промисах, и в таймаутах, и в нативных подписках. Пять setState подряд дают один рендер. Если очень нужно синхронно применить обновление и прочитать DOM (редкий случай), есть flushSync из react-dom — но он выключает батчинг и стоит целого кадра.
Бэйл-аут по Object.is. Если новое значение равно старому по Object.is, React может пропустить перерисовку поддерева. Именно поэтому мутация объекта не работает: ссылка та же, значит «ничего не изменилось».
// НЕ РАБОТАЕТ: ссылка не поменялась
user.name = 'Ира';
setUser(user);
// Работает: новая ссылка
setUser(prev => ({ ...prev, name: 'Ира' }));
Для глубоко вложенных структур ручной спред превращается в кошмар — берите Immer и пишите мутирующий код, который под капотом создаёт новые ссылки.
Сколько состояний заводить. Разделяйте то, что меняется независимо (text и isOpen), и объединяйте то, что меняется вместе (x и y курсора). Признак того, что состояний слишком много: вы вынуждены обновлять три из них в одном обработчике, иначе UI окажется в невозможном виде — тогда пора в useReducer.
Сброс состояния через key. Классическая ошибка — зеркалить проп в состояние и потом «синхронизировать» эффектом. Правильный инструмент — key: смена ключа уничтожает fiber вместе со всеми слотами хуков и монтирует компонент заново.
// Каждый новый userId — чистая форма без ручных сбросов
<ProfileForm key={userId} userId={userId} />
Подстройка состояния прямо в рендере. Если сброс нужен частичный, React разрешает вызвать setState во время рендера того же компонента — он немедленно перезапустит рендер, не трогая DOM и не показывая промежуточный кадр. Это дешевле эффекта:
const [selection, setSelection] = useState<string | null>(null);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) { // сравниваем с прошлым пропом прямо в рендере
setPrevItems(items);
setSelection(null); // React перезапустит рендер, пейнта не будет
}
useReducer: когда пары полей стало мало
useReducer — тот же useState, но переход состояния описан данными, а не разбросан по обработчикам. Берите его, когда: состояний больше трёх и они связаны; следующее состояние зависит от предыдущего; переходы надо тестировать отдельно от компонента; логику хочется вынести за пределы React.
// Размеченное объединение: невозможные состояния просто нельзя выразить
type State =
| { status: 'idle' } | { status: 'loading' }
| { status: 'ready'; data: Order[] } | { status: 'error'; message: string };
type Action =
| { type: 'fetch' } | { type: 'resolved'; data: Order[] }
| { type: 'rejected'; message: string } | { type: 'retry' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'fetch': return { status: 'loading' };
case 'resolved': return { status: 'ready', data: action.data };
case 'rejected': return { status: 'error', message: action.message };
case 'retry': return state.status === 'error' ? { status: 'loading' } : state;
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { status: 'idle' });
Два бонуса. Первый: размеченное объединение делает невозможные состояния непредставимыми — не бывает loading: true вместе с data. Второй: dispatch стабилен по ссылке между рендерами, его можно свободно класть в зависимости и прокидывать через контекст, не заворачивая в useCallback. Про типизацию таких объединений подробно — в треке TypeScript, основы типов.
Редьюсер — чистая функция, поэтому тестируется без рендера вообще: expect(reducer({status:'loading'}, {type:'resolved', data: []})).toEqual(...).
useEffect — это синхронизация, а не жизненный цикл
Самая дорогая ошибка в React — читать useEffect как «componentDidMount + componentDidUpdate». Правильное чтение: эффект синхронизирует внешнюю систему с текущим состоянием компонента. Внешняя система — это всё, что не под управлением React: DOM вне дерева, сеть, таймеры, WebSocket, подписки браузера, аналитика.
У эффекта три части: тело (установить синхронизацию), функция очистки (снять её), массив зависимостей (какой снимок описывает синхронизацию). Если вы смотрите на эффект как на «действие при монтировании», очистка кажется формальностью. Если как на синхронизацию — очистка обязательна по симметрии.
Канонический пример — подписка на комнату чата:
function ChatRoom({ roomId, serverUrl }: { roomId: string; serverUrl: string }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect(); // очистка симметрична телу
}, [roomId, serverUrl]); // весь снимок, от которого зависит связь
...
}
Меняется roomId — React отключается от старой комнаты и подключается к новой. Не «обновляет подписку», а именно снимает и ставит заново. Это и есть модель: эффект описывает, как выглядит правильная синхронизация для данного снимка, а не последовательность действий во времени.
StrictMode. В разработке React 18+ монтирует компонент дважды: effect → cleanup → effect. Это не баг и не то, что происходит в проде, — это детектор эффектов без корректной очистки. Если ваш компонент ломается в StrictMode (двойные запросы, дублирующиеся подписки, счётчик +2), он сломается и в проде при переиспользовании состояния и в будущих режимах React. Чините эффект, а не выключайте StrictMode.
Зависимости — не настройка, а следствие. Линтер exhaustive-deps вычисляет их механически: всё реактивное, что использовано внутри, должно быть в списке. Удалять зависимость, чтобы «эффект не срабатывал лишний раз», — гарантированный баг со «старым состоянием». Если список неудобен, чинить надо не список, а эффект: перенести создание функции внутрь эффекта, вынести объект наружу компонента, разбить один эффект на два (у каждого свой набор deps), заменить объект на примитив.
// Плохо: options — новый объект на каждом рендере, эффект перезапускается вечно
const options = { serverUrl, roomId };
useEffect(() => connect(options), [options]);
// Хорошо: зависимости — примитивы, объект создаём внутри
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
Гонки: главный баг эффектов с сетью
Запросы возвращаются не в том порядке, в котором отправлены. Без защиты компонент показывает данные от устаревшего запроса.
Лечится флагом в замыкании и отменой запроса. Флаг обязателен даже с AbortController: между abort() и реальным отбрасыванием промиса может проскочить .then.
useEffect(() => {
let ignore = false;
const controller = new AbortController();
setStatus('loading');
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then(data => {
if (ignore) return; // ответ от устаревшего снимка — игнорируем
setUser(data);
setStatus('ready');
})
.catch(err => {
if (ignore || err.name === 'AbortError') return;
setStatus('error');
});
return () => { ignore = true; controller.abort(); };
}, [userId]);
Этот код правильный — и именно поэтому его не стоит писать руками в каждом компоненте. Кэш, дедупликация, повторы, фоновое обновление, инвалидация — всё это уже решено в TanStack Query; подробно в статье про работу с данными.
Вам, скорее всего, не нужен useEffect
Более половины эффектов в реальных проектах лишние. Эффект нужен только для выхода за пределы React. Всё остальное считается в рендере или делается в обработчике события. Официальный разбор — «You Might Not Need an Effect»; ниже — рабочее решающее дерево.
и состояния?"} B -- Да --> B1["Считай прямо в рендере.
useMemo только если реально дорого"] B -- Нет --> C{"Это реакция на конкретное
действие пользователя?"} C -- Да --> C1["Логика в обработчике события,
не в эффекте"] C -- Нет --> D{"Нужно сбросить состояние
при смене сущности?"} D -- Да --> D1["Смени key или сравни
прошлый проп в рендере"] D -- Нет --> E{"Речь о внешней системе:
DOM, сеть, таймер, подписка?"} E -- Да --> E1["Это настоящий эффект.
Пиши очистку сразу"] E -- Нет --> F["Скорее всего эффект лишний:
перечитай пункты выше"]
| Антипаттерн | Симптом | Замена |
|---|---|---|
| Эффект считает производное состояние | лишний рендер, состояние отстаёт на шаг | вычислять в теле рендера |
| Эффект фильтрует/сортирует список | тормоза, дублирование данных | const visible = useMemo(...) |
| Эффект сбрасывает состояние при смене пропа | мигание старых данных | key или сравнение в рендере |
| Эффект отправляет аналитику после клика | двойная отправка в StrictMode | вызов в обработчике |
| Цепочка эффектов «а после этого ещё» | каскад рендеров, гонки | посчитать всё сразу в одном обработчике |
| Эффект инициализирует приложение | двойной запуск в StrictMode | вынести вызов на уровень модуля |
| Эффект подписывается на внешний стор | tearing, рассинхрон | useSyncExternalStore |
useLayoutEffect, порядок фаз и SSR
useEffect выполняется асинхронно после того, как браузер нарисовал кадр. useLayoutEffect — синхронно до пейнта, сразу после мутации DOM. Разница видна только в одном классе задач: когда нужно измерить реальную геометрию и поправить её так, чтобы пользователь не увидел промежуточное состояние.
function Tooltip({ targetRect, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
// Синхронно: измеряем и решаем, показать сверху или снизу — до пейнта.
// С useEffect пользователь увидел бы кадр с тултипом в неправильном месте.
useLayoutEffect(() => {
setHeight(ref.current!.getBoundingClientRect().height);
}, []);
const top = targetRect.top - height < 0 ? targetRect.bottom : targetRect.top - height;
return <div ref={ref} style={{ position: 'absolute', top }}>{children}</div>;
}
Цена: useLayoutEffect блокирует отрисовку. Тяжёлая работа внутри напрямую бьёт по времени кадра и по INP — как это измерять, разбираем в статье о производительности. Как браузер планирует layout и paint — в «Как работает браузер».
Отдельная ловушка: на сервере useLayoutEffect не выполняется и React выдаёт предупреждение при SSR. Если хук должен работать в обеих средах, используйте условный алиас (typeof window !== 'undefined' ? useLayoutEffect : useEffect) или изоморфную обёртку — детали гидратации в статье про рендеринг.
useRef: ящик, переживающий рендеры
useRef возвращает стабильный объект { current }. Два применения: ссылка на DOM-узел и мутабельное значение, изменение которого не должно вызывать рендер (id таймера, предыдущее значение, флаг «уже отправляли», инстанс сторонней библиотеки).
Разделение простое: если значение влияет на то, что видно на экране — это состояние; если нет — это ref.
const timer = useRef<number | null>(null); // id таймера: рендер не нужен
const inputRef = useRef<HTMLInputElement>(null); // DOM-узел
useEffect(() => { inputRef.current?.focus(); }, []);
Latest ref pattern. Иногда эффект должен использовать свежий колбэк, но не перезапускаться при его изменении — например, подписка, которая живёт всё время жизни компонента, но вызывает актуальный обработчик. Это ручная эмуляция будущего useEffectEvent:
export function useEventCallback<A extends unknown[], R>(fn: (...args: A) => R) {
const ref = useRef(fn);
// layout-фаза: обновляем до того, как что-либо успеет вызвать колбэк
useLayoutEffect(() => { ref.current = fn; });
return useCallback((...args: A) => ref.current(...args), []);
}
Возвращённая функция стабильна навсегда, но всегда вызывает последнюю версию fn. Идею и границы применимости описали в RFC про useEffectEvent: в эффекты можно класть, в JSX-пропсы дочерних компонентов при конкурентном рендеринге — с осторожностью.
React 19 упростил рефы. forwardRef больше не нужен — ref приходит обычным пропом. А колбэк-реф теперь может вернуть функцию очистки:
<div ref={(node) => {
if (!node) return;
observer.observe(node);
return () => observer.unobserve(node); // React 19: очистка вместо вызова с null
}} />
useImperativeHandle нужен редко — когда компонент осознанно публикует императивный API (focus(), scrollToRow(), play()). Не превращайте его в способ дёргать внутреннее состояние снаружи: это ломает однонаправленный поток данных.
Мемоизация: честная цена
useMemo, useCallback и memo — не «ускорители», а инструменты с конкретной ценой: дополнительная память, сравнение зависимостей на каждом рендере, усложнённый код. Kent C. Dodds посчитал это в «When to useMemo and useCallback»: для дешёвых вычислений мемоизация обходится дороже самого вычисления.
Что они делают буквально:
useMemo(fn, deps)— кэширует значение между рендерами, покаdepsравны поObject.is.useCallback(fn, deps)— то же самое для функции; это ровноuseMemo(() => fn, deps).memo(Component)— пропускает рендер компонента, если все пропсы поверхностно равны прошлым.
Мемоизация даёт выигрыш только когда выполнены все три условия: (1) компонент действительно перерисовывается часто, (2) его рендер дорогой или он корень большого поддерева, (3) пропсы стабильны по ссылке. Нарушено любое — вы платите, не получая ничего.
// Бесполезно: memo проверит пропсы, но onSelect — новая функция на каждом рендере
const Row = memo(function Row({ item, onSelect }: RowProps) { ... });
function List({ items }: { items: Item[] }) {
return items.map(item => (
<Row key={item.id} item={item} onSelect={() => open(item.id)} /> // ← ломает memo
));
}
Чинится либо useCallback со стабильным колбэком и передачей id внутрь, либо — что часто лучше — изменением структуры. Самый недооценённый приём: поднять дорогое поддерево в проп children, чтобы оно создавалось в родителе, который не перерисовывается.
// Было: <ExpensiveTree /> внутри App — любое изменение color перерисовывает его.
// Стало: элемент создан в App, который не перерисовывается, и приходит пропом.
// React видит ту же ссылку на элемент и пропускает поддерево без всякого memo.
function ColorBox({ children }: { children: ReactNode }) {
const [color, setColor] = useState('#fff');
return (
<div style={{ color }}>
<input value={color} onChange={e => setColor(e.target.value)} />
{children}
</div>
);
}
function App() {
return <ColorBox><ExpensiveTree /></ColorBox>;
}
Это композиция вместо мемоизации: дешевле, надёжнее и не требует поддержки списка зависимостей.
useMemo обязателен в двух случаях, где дело не в скорости, а в корректности: когда значение попадает в deps другого хука, и когда объект передаётся в value контекста (иначе все потребители перерисовываются на каждый рендер провайдера).
const value = useMemo(() => ({ user, logout }), [user, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
Конкурентные альтернативы. Иногда правильный ответ — не мемоизировать, а понизить приоритет работы. useDeferredValue показывает устаревшее значение, пока считается новое; useTransition помечает обновление как некритичное, чтобы ввод оставался отзывчивым.
function SearchResults({ query }: { query: string }) {
const deferred = useDeferredValue(query); // отстаёт от ввода
const list = useMemo(() => filterHugeList(deferred), [deferred]);
const stale = query !== deferred;
return <div style={{ opacity: stale ? 0.6 : 1 }}>{list.map(renderRow)}</div>;
}
React Compiler. В React 19 появился компилятор, который автоматически расставляет мемоизацию на этапе сборки, анализируя, какие значения реально меняются. Он снимает необходимость руками писать useMemo/useCallback — но требует, чтобы код следовал правилам React (никаких мутаций пропсов и состояния во время рендера). Документация и настройка — react.dev/learn/react-compiler. Практический совет на 2026 год: включайте его линт-правило уже сейчас, оно ловит нарушения чистоты независимо от того, используете вы компилятор или нет.
Как измерять, а не гадать
Любая оптимизация без измерения — суеверие. Порядок действий такой.
- React DevTools Profiler. Записываете взаимодействие, смотрите flame-график коммитов. Ranked-вид сразу показывает самые дорогие компоненты. В настройках включите «Record why each component rendered» — DevTools подпишет причину: изменился проп, состояние, контекст или родитель.
- Highlight updates. Галочка в настройках DevTools подсвечивает перерисовываемые узлы прямо на странице. Мигает весь экран на ввод одного символа — состояние поднято слишком высоко.
<Profiler>в коде. Программный замер, который можно отправлять в аналитику и следить за регрессиями:
import { Profiler, type ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (id, phase, actual, base, start, commit) => {
// actual — сколько заняло это обновление; base — сколько заняло бы без мемоизации
if (actual > 16) {
analytics.track('slow_render', { id, phase, actual: Math.round(actual) });
}
};
<Profiler id="OrdersTable" onRender={onRender}>
<OrdersTable />
</Profiler>
- Performance-панель браузера. React расставляет метки в User Timings, так что видно, какая часть длинной задачи — это ваш рендер, а какая — layout или парсинг. Это единственный способ связать рендеры с реальным INP.
Важно: профилируйте production-сборку (или профилировочную сборку React) и на дросселированном CPU 4–6x. В dev-режиме React делает много лишней работы, и цифры вводят в заблуждение.
Кастомные хуки: композиция логики
Кастомный хук — это просто функция, имя которой начинается с use и которая вызывает другие хуки. Никакой магии: хук делится логикой, а не состоянием. Два компонента, вызывающие useCounter(), получат два независимых счётчика. Если нужно общее состояние — это контекст или внешний стор, тема следующей статьи.
Хорошие кастомные хуки описывают задачу предметной области («подписаться на статус соединения»), а не механику React («сделать useEffect с deps»).
// Дебаунс значения: типовой хук для поиска
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // отменяем предыдущий таймер — вот вся суть
}, [value, delay]);
return debounced;
}
// Медиазапрос через useSyncExternalStore: без гонок и без лишнего состояния
export function useMediaQuery(query: string): boolean {
const subscribe = useCallback((cb: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
}, [query]);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches, // снимок для клиента
() => false, // снимок для сервера при SSR
);
}
useSyncExternalStore и проблема tearing
Почему для внешних источников нужен отдельный хук, а не useEffect + useState? Из-за конкурентного рендеринга: React может прервать рендер, а внешние данные — измениться посередине. Тогда одна часть дерева отрисуется со старым значением, другая с новым. Это и есть tearing — разрыв визуальной консистентности. useSyncExternalStore гарантирует, что все компоненты в одном коммите видят один снимок.
Любой внешний источник подключается тремя функциями: subscribe(callback) возвращает отписку, getSnapshot() отдаёт текущее значение, getServerSnapshot() — значение для SSR. Именно на этом хуке построены адаптеры Redux, Zustand и Jotai к React.
Критично: getSnapshot обязан возвращать кэшированное значение. Если вернуть { ...state } или state.items.filter(...), каждая проверка даст новую ссылку, React решит, что стор изменился, и уйдёт в бесконечный цикл. Селекторы делают через useSyncExternalStoreWithSelector из use-sync-external-store/shim/with-selector либо мемоизируют вручную. Справочник и предупреждения — react.dev/reference/react/useSyncExternalStore.
Ещё два маленьких, но полезных хука
useId генерирует стабильный между сервером и клиентом идентификатор — незаменим для связи label и input, aria-describedby и подсказок. Не используйте его для ключей списка. Почему это важно для доступности — в гайде по accessibility.
const id = useId();
<label htmlFor={`${id}-email`}>Почта</label>
<input id={`${id}-email`} aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Пришлём чек на этот адрес</p>
useDebugValue подписывает кастомный хук в React DevTools: useDebugValue(isOnline ? 'online' : 'offline'). Стоит копейки, экономит минуты отладки в больших деревьях.
Паттерны композиции компонентов
Хуки решают переиспользование логики. Переиспользование разметки и поведения решают паттерны композиции.
Compound components
Набор компонентов, которые вместе образуют один виджет и общаются через приватный контекст. Пользователь получает свободу верстки, вы — контроль над состоянием.
type TabsContextValue = { activeId: string; setActive: (id: string) => void; baseId: string };
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs(component: string) {
const ctx = useContext(TabsContext);
// Явная ошибка вместо загадочного undefined — обязательный элемент хорошего API
if (!ctx) throw new Error(`<${component}> должен использоваться внутри <Tabs>`);
return ctx;
}
export function Tabs({ defaultId, children }: { defaultId: string; children: ReactNode }) {
const [activeId, setActive] = useState(defaultId);
const baseId = useId();
const value = useMemo(() => ({ activeId, setActive, baseId }), [activeId, baseId]);
return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}
export const TabList = ({ children }: { children: ReactNode }) => <div role="tablist">{children}</div>;
export function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeId, setActive, baseId } = useTabs('Tab');
const selected = activeId === id;
return (
<button
role="tab"
id={`${baseId}-tab-${id}`}
aria-selected={selected}
aria-controls={`${baseId}-panel-${id}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(id)}
>
{children}
</button>
);
}
export function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeId, baseId } = useTabs('TabPanel');
if (activeId !== id) return null;
return <div role="tabpanel" id={`${baseId}-panel-${id}`} aria-labelledby={`${baseId}-tab-${id}`}>{children}</div>;
}
Использование читается как разметка, а состояние спрятано:
<Tabs defaultId="orders">
<TabList>
<Tab id="orders">Заказы</Tab>
<Tab id="returns">Возвраты</Tab>
</TabList>
<TabPanel id="orders"><OrdersTable /></TabPanel>
<TabPanel id="returns"><ReturnsTable /></TabPanel>
</Tabs>
Контролируемый и неконтролируемый компонент
Хороший компонент библиотеки поддерживает оба режима: defaultValue — состояние внутри, value + onChange — состояние снаружи. Один хук закрывает оба случая:
export function useControllableState<T>({
value, defaultValue, onChange,
}: { value?: T; defaultValue: T; onChange?: (next: T) => void }) {
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const isControlled = value !== undefined;
const state = isControlled ? (value as T) : uncontrolled;
const setState = useEventCallback((next: T) => {
if (!isControlled) setUncontrolled(next); // внутренним состоянием управляем сами
onChange?.(next); // наружу сообщаем всегда
});
return [state, setState] as const;
}
Тонкость: переключать компонент между режимами на лету нельзя — React выдаст предупреждение о смене controlled/uncontrolled. Подробнее про это в контексте форм — в статье про формы и валидацию.
Headless-компоненты
Логика и доступность отдельно, оформление — ваше. Так устроены Radix UI, Headless UI, TanStack Table, React Aria: они дают хуки и примитивы с корректной клавиатурной навигацией, фокус-ловушками и ARIA, но не навязывают ни пикселя стилей. Это лучший компромисс для дизайн-системы: чужую логику модального окна с фокус-трапом писать заново дорого и рискованно, а чужие стили всё равно придётся переписать. Как это стыкуется со слоями стилей — в архитектуре стилей.
Render props и state reducer — живы, но в узкой нише
Проп-функция, возвращающая разметку (<VirtualList>{(row, style) => ...}</VirtualList>), осталась там, где владелец данных должен контролировать момент их рендера: виртуализированные списки, графики, drag-and-drop. В остальных случаях кастомный хук читается лучше.
State reducer — приём для библиотек: компонент принимает извне редьюсер и тем самым позволяет переопределить любое внутреннее решение («не закрывать выпадашку после выбора»). Цена — публичный контракт на внутренние действия, который нельзя менять без мажорной версии.
Типичные ошибки
- Хук в условии или цикле. Слоты разъезжаются, компонент падает или молча читает чужое состояние. Линтер ловит это в ста процентах случаев.
- Замалчивание
exhaustive-deps.// eslint-disable-next-lineнад эффектом почти всегда означает баг со «старым состоянием» через месяц. Если очень надо — сначала попробуйте latest ref pattern. useStateдля того, что не рисуется. Счётчики, таймеры, флаги «уже отправили» — этоuseRef.- Зеркалирование пропа в состояние.
const [name, setName] = useState(props.name)— источник рассинхрона. Считайте в рендере или сбрасывайте черезkey. - Мутация состояния.
items.push(x); setItems(items)не перерисует ничего. - Один эффект на всё. Если тело эффекта делает две несвязанные вещи, разбейте на два — у каждого свой список зависимостей и своя очистка.
- Забытая очистка. Подписки, таймеры и обсерверы без cleanup — утечка памяти и «зомби-обновления» после размонтирования.
- Новый объект в
valueконтекста. Все потребители перерисовываются на каждый рендер провайдера; лечитсяuseMemo. useCallbackбезmemoна приёмнике. Стабильная ссылка никому не нужна, если дочерний компонент и так перерисовывается. Чистые расходы.flushSync«чтобы точно применилось». Выключает батчинг и почти всегда означает, что задача решается не так.- Отключение StrictMode из-за двойных вызовов. Вы не чините баг, а прячете его до продакшена.
Тестирование хуков
Кастомные хуки тестируются через renderHook из React Testing Library — без компонента-обёртки и без DOM-запросов:
import { renderHook, act } from '@testing-library/react';
test('useDebouncedValue отдаёт значение только после задержки', () => {
vi.useFakeTimers();
const hook = renderHook(({ v }) => useDebouncedValue(v, 300), { initialProps: { v: 'a' } });
hook.rerender({ v: 'ab' });
expect(hook.result.current).toBe('a'); // ещё старое значение
act(() => { vi.advanceTimersByTime(300); });
expect(hook.result.current).toBe('ab'); // задержка прошла
});
Важный принцип: тестируйте поведение компонента, а не внутренности хука. renderHook уместен для «библиотечных» хуков; для хуков, тесно связанных с UI, честнее написать компонентный тест. Подробнее — в статье про тестирование фронтенда.
Мини-итог
- Рендер — это снимок. Пропсы, состояние и обработчики внутри одного вызова заморожены;
setStateпланирует следующий снимок, а не меняет переменную. - Хуки живут в fiber как список слотов и выдаются строго по порядку вызова — отсюда правила хуков и линтер, с которым не спорят.
- Эффект — это синхронизация с внешним миром, а не «событие жизненного цикла». Есть тело — должна быть очистка; зависимости выводятся механически, а не подбираются.
- Большая часть эффектов лишняя: производные значения считаются в рендере, реакции на действия живут в обработчиках, сброс делается через
key. useLayoutEffect— только для измерений до пейнта; он блокирует кадр.- Мемоизация окупается лишь при частых рендерах дорогого поддерева со стабильными пропсами. Композиция через
childrenчасто дешевле и надёжнее; React Compiler постепенно снимает эту рутину. - Кастомные хуки делят логику, а не состояние; для внешних источников —
useSyncExternalStore. - Паттерны компоновки (compound components, controlled/uncontrolled, headless) дают библиотечное качество API без потери гибкости вёрстки.
Источники
- React: справочник хуков — сигнатуры, предупреждения, edge cases.
- React: Synchronizing with Effects и You Might Not Need an Effect — обязательное чтение перед каждым новым
useEffect. - Dan Abramov, A Complete Guide to useEffect — модель снимков, разобранная до основания.
- Dan Abramov, Why Do React Hooks Rely on Call Order? — почему слоты, а не имена.
- Mark Erikson, A (Mostly) Complete Guide to React Rendering Behavior — что именно вызывает перерисовку.
- Kent C. Dodds, When to useMemo and useCallback — арифметика цены мемоизации.
- Hooks RFC — исходная мотивация и отвергнутые альтернативы.
- React Compiler — автоматическая мемоизация и требования к чистоте кода.
- Testing Library: renderHook — тестирование кастомных хуков.
Что дальше
Мы разобрали состояние внутри компонента и способы делить логику между компонентами. Остался вопрос, который начинается там, где заканчивается useState: где хранить состояние, нужное половине приложения, и как не превратить его в кашу из контекстов. Дальше — Управление состоянием: локальное, контекст, Redux, Zustand, сигналы.