Frontend-разработка Хуки и паттерны React: useState, useEffect, мемоизация, композиция
0%

Хуки и паттерны React: useState, useEffect, мемоизация, композиция

Хуки и паттерны 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 идёт по этому списку по порядку вызовов: первый вызванный хук получает первый узел, второй — второй.

Хуки хранятся как список слотов в fiber; условный вызов сдвигает слоты

Отсюда — единственное настоящее ограничение хуков: вызывать их можно только на верхнем уровне компонента или другого хука, всегда в одинаковом порядке и одинаковом количестве. Никаких 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»; ниже — рабочее решающее дерево.

Антипаттерн Симптом Замена
Эффект считает производное состояние лишний рендер, состояние отстаёт на шаг вычислять в теле рендера
Эффект фильтрует/сортирует список тормоза, дублирование данных const visible = useMemo(...)
Эффект сбрасывает состояние при смене пропа мигание старых данных key или сравнение в рендере
Эффект отправляет аналитику после клика двойная отправка в StrictMode вызов в обработчике
Цепочка эффектов «а после этого ещё» каскад рендеров, гонки посчитать всё сразу в одном обработчике
Эффект инициализирует приложение двойной запуск в StrictMode вынести вызов на уровень модуля
Эффект подписывается на внешний стор tearing, рассинхрон useSyncExternalStore

useLayoutEffect, порядок фаз и SSR

useEffect выполняется асинхронно после того, как браузер нарисовал кадр. useLayoutEffect — синхронно до пейнта, сразу после мутации DOM. Разница видна только в одном классе задач: когда нужно измерить реальную геометрию и поправить её так, чтобы пользователь не увидел промежуточное состояние.

Порядок фаз одного обновления React и место эффектов

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 год: включайте его линт-правило уже сейчас, оно ловит нарушения чистоты независимо от того, используете вы компилятор или нет.

Как измерять, а не гадать

Любая оптимизация без измерения — суеверие. Порядок действий такой.

  1. React DevTools Profiler. Записываете взаимодействие, смотрите flame-график коммитов. Ranked-вид сразу показывает самые дорогие компоненты. В настройках включите «Record why each component rendered» — DevTools подпишет причину: изменился проп, состояние, контекст или родитель.
  2. Highlight updates. Галочка в настройках DevTools подсвечивает перерисовываемые узлы прямо на странице. Мигает весь экран на ввод одного символа — состояние поднято слишком высоко.
  3. <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>
  1. 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 без потери гибкости вёрстки.

Источники

Что дальше

Мы разобрали состояние внутри компонента и способы делить логику между компонентами. Остался вопрос, который начинается там, где заканчивается useState: где хранить состояние, нужное половине приложения, и как не превратить его в кашу из контекстов. Дальше — Управление состоянием: локальное, контекст, Redux, Zustand, сигналы.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов