Введение в React Hooks
Хуки появились в React 16.8 и с тех пор стали основным способом писать компоненты. Эта статья — не справочник по API, а маршрут: что такое хук, зачем он нужен, в каком порядке их осваивать и на чём спотыкаются почти все.
Примеры здесь запускаются прямо в тексте. Каждая вставка — настоящий проект в браузере: устанавливаются зависимости, поднимается Vite, работает горячая перезагрузка. Меняйте код и смотрите, что получится — сломать ничего нельзя.
Зачем понадобились хуки
До хуков состояние жило только в классовых компонентах. Функция умела принимать пропсы и возвращать разметку, и всё: как только компоненту требовалось что-то запомнить, его переписывали в класс.
Проблема была не в многословности, а в том, что логика размазывалась по методам жизненного цикла. Подписка на событие создавалась в componentDidMount, обновлялась в componentDidUpdate и убиралась в componentWillUnmount. Три метода, один смысл. А рядом, в тех же методах, лежала совсем другая логика — загрузка данных, таймер, аналитика. Связанное оказывалось разнесено, несвязанное — склеено.
Хуки перевернули разрезание: код группируется не по моменту жизни компонента, а по смыслу. Подписка целиком — в одном useEffect, загрузка данных — в другом.
Хук — это обычная функция, которая умеет «цепляться» к компоненту: сохранять между рендерами данные и подключаться к его жизненному циклу. Отличить хук легко по имени: оно начинается с use.
useState: компонент, который что-то помнит
useState возвращает пару: текущее значение и функцию, которая его меняет.
1const [count, setCount] = useState(0);Аргумент useState — начальное значение, оно используется только при первом рендере. Вызов setCount просит React перерисовать компонент с новым значением.
Ключевое, что стоит уложить в голову сразу: count — не переменная, которую вы меняете, а снимок значения на текущий рендер. Каждый рендер видит своё count, и оно неизменно до следующего рендера.
Запустите пример и попробуйте поменять шаг счётчика или начальное значение:
Обновление на основе прошлого значения
Попробуйте в примере выше заменить обработчик на такой:
1<button onClick={() => { setCount(count + 1); setCount(count + 1); }}>
2 Прибавить дважды
3</button>Счётчик вырастет на единицу, а не на двойку. Оба вызова прочитали одно и то же count из текущего рендера — то самое «значение это снимок».
Правильный способ — передать функцию: React вызовет её с самым свежим значением.
1setCount((prev) => prev + 1);
2setCount((prev) => prev + 1); // теперь будет +2Правило простое: если новое состояние считается из старого, передавайте функцию.
Состояние нельзя менять на месте
React сравнивает старое и новое значение по ссылке. Если положить в состояние объект и изменить его поле, ссылка останется прежней, и React не увидит разницы — перерисовки не будет.
1// Так не сработает
2user.name = 'Аня';
3setUser(user);
4
5// Так правильно: новый объект
6setUser({ ...user, name: 'Аня' });
7
8// То же с массивами
9setItems([...items, newItem]); // добавить
10setItems(items.filter((i) => i.id !== id)); // удалить
11setItems(items.map((i) => (i.id === id ? { ...i, done: true } : i))); // изменитьСоберём это в списке дел. Обратите внимание, как каждое действие создаёт новый массив:
Заметьте key={item.id} в списке. Ключ нужен React, чтобы понимать, какой элемент какому соответствует между рендерами. Индекс массива в роли ключа — источник трудноуловимых багов: при вставке в начало списка все индексы съезжают, и React считает, что изменились все элементы.
Правила хуков
Правил всего два, и оба не про стиль, а про то, как хуки устроены внутри.
Хуки вызываются только на верхнем уровне компонента. Не в условиях, не в циклах, не внутри вложенных функций.
1// Нельзя
2if (isLoggedIn) {
3 const [name, setName] = useState('');
4}
5
6// Можно: условие внутри, вызов снаружи
7const [name, setName] = useState('');
8if (isLoggedIn) { /* ... */ }Причина в том, что React не знает имён ваших переменных. Он хранит состояния компонента списком и на каждом рендере раздаёт их по порядку вызовов: первый useState получает первую ячейку, второй — вторую. Спрячьте один вызов под условие — порядок собьётся, и компонент получит чужое состояние.
Хуки вызываются только из React-компонентов и других хуков. Обычная функция не привязана к компоненту, и цепляться хуку будет не к чему.
Оба правила проверяет плагин eslint-plugin-react-hooks — держите его включённым, он ловит нарушения до того, как они станут багами.
useEffect: выход во внешний мир
Рендер компонента должен быть чистым: получили пропсы и состояние, вернули разметку. Но приложению нужно и другое — запросы к серверу, подписки на события, таймеры, работа с DOM напрямую. Всё это побочные эффекты, и им место в useEffect.
1useEffect(() => {
2 // эффект
3 return () => {
4 // очистка
5 };
6}, [deps]);Три части, и каждая отвечает за своё.
Тело эффекта выполняется после рендера — когда разметка уже на экране.
Массив зависимостей решает, когда эффект запускать заново. React сравнивает значения в массиве с предыдущими и, если что-то изменилось, сначала выполняет очистку, потом эффект заново.
[]— один раз при монтировании;[userId]— при монтировании и каждый раз, когда меняетсяuserId;- массива нет вовсе — после каждого рендера (нужно редко).
Функция очистки — то, что эффект возвращает. React вызовет её перед следующим запуском эффекта и при размонтировании компонента. Здесь отписываются от событий и убирают таймеры.
Пропущенная очистка — самая частая утечка в React-приложениях. В примере ниже таймер живёт в useEffect; попробуйте убрать return с clearInterval и снять галочку — интервал продолжит работать, и при следующем включении их станет два.
Бесконечный цикл
Классическая ошибка выглядит так:
1useEffect(() => {
2 setData([...data, newItem]); // меняем то, от чего зависим
3}, [data]);Эффект меняет data, изменение data запускает эффект, и так до бесконечности. Лечится либо функциональным обновлением, либо честным пересмотром зависимостей: часто выясняется, что эффект здесь вообще не нужен.
Кстати, об этом. Прежде чем писать useEffect, стоит спросить: точно ли нужен эффект? Если значение можно вычислить прямо во время рендера — вычисляйте, не заводите для него состояние.
1// Лишний эффект
2const [fullName, setFullName] = useState('');
3useEffect(() => setFullName(first + ' ' + last), [first, last]);
4
5// Достаточно обычной переменной
6const fullName = first + ' ' + last;Загрузка данных и гонки
Если эффект что-то загружает, помните: ответы приходят не в том порядке, в каком уходили запросы. Пользователь быстро переключил id, первый ответ пришёл позже второго — и на экране оказались данные от старого запроса.
1useEffect(() => {
2 let cancelled = false;
3
4 fetch(`/api/users/${userId}`)
5 .then((res) => res.json())
6 .then((data) => {
7 if (!cancelled) setUser(data); // ответ устаревшего запроса игнорируем
8 });
9
10 return () => {
11 cancelled = true;
12 };
13}, [userId]);useRef: значение мимо рендера
useRef создаёт коробочку, которая переживает рендеры, но, в отличие от состояния, её изменение не вызывает перерисовку.
Два основных применения.
Ссылка на DOM-элемент:
1const inputRef = useRef(null);
2// ...
3<input ref={inputRef} />
4<button onClick={() => inputRef.current.focus()}>В поле</button>Хранилище для того, что не влияет на разметку — идентификатор таймера, предыдущее значение, флаг «первый рендер».
1const renders = useRef(0);
2renders.current += 1; // перерисовки не будетПростое правило выбора: если значение видно на экране — это состояние. Если оно нужно только коду — это ref.
useContext: данные сквозь дерево
Когда значение нужно многим компонентам на разной глубине, прокидывать его пропсами утомительно: промежуточные компоненты вынуждены принимать и передавать то, что им самим не нужно. Это называют «пропс-дриллингом», и лечит его контекст.
Контекст состоит из трёх частей: создали (createContext), обернули поддерево в Provider со значением, прочитали значение в любом потомке через useContext.
В примере ниже тема лежит в контексте, а кнопка переключения — на три уровня глубже провайдера и получает её напрямую. Файлов несколько — переключайтесь между ними в дереве слева:
Контекст — не замена состоянию приложения. Он хорош для того, что меняется редко и нужно многим: тема, язык, текущий пользователь. Часто меняющееся значение в контексте перерисовывает всех потребителей сразу.
useReducer: состояние со сложными переходами
useState перестаёт быть удобным, когда состояний несколько и меняются они вместе. Форма с полями, флагом загрузки и ошибкой — это четыре-пять вызовов useState и обработчики, которые дёргают их по три штуки за раз. Логика расползается по компоненту, и уследить за тем, какие сочетания вообще возможны, становится трудно.
useReducer собирает переходы в одном месте:
1const [state, dispatch] = useReducer(reducer, initialState);Редьюсер — чистая функция (state, action) => newState. Компонент больше не описывает, как менять состояние, — он сообщает, что произошло: dispatch({ type: 'submit' }). Решение о новом состоянии принимает редьюсер.
Выгода не только в порядке. Редьюсер — обычная функция вне компонента, её легко покрыть тестами. А dispatch, в отличие от набора обработчиков, имеет стабильную ссылку — его можно спокойно передавать вглубь дерева.
Берите useReducer, когда следующее состояние зависит от предыдущего нетривиально, когда переходов много или когда несколько значений всегда меняются вместе. Для одного флага он избыточен.
Обратите внимание на selectTotal: сумма не хранится в состоянии, а вычисляется из него. Хранить её отдельно означало бы поддерживать согласованность руками — и однажды забыть.
useMemo и useCallback: осторожно с оптимизацией
useMemo запоминает результат вычисления, useCallback — саму функцию. Оба пересчитывают значение, только когда меняются зависимости.
1const sorted = useMemo(() => items.sort(compare), [items]);
2const handleClick = useCallback(() => send(id), [id]);Соблазн обернуть в них всё подряд велик, но мемоизация не бесплатна: React хранит прошлое значение и сравнивает зависимости на каждом рендере. Для сложения двух чисел это дороже самого сложения.
Реальные поводы всего два: тяжёлое вычисление, которое заметно тормозит рендер, и стабильная ссылка, нужная другому хуку или компоненту под React.memo. Во всех остальных случаях сначала измеряйте, потом оптимизируйте.
useLayoutEffect: измерения до отрисовки
useLayoutEffect устроен как useEffect, но выполняется раньше: после того, как React обновил DOM, но до того, как браузер успел нарисовать кадр. React дожидается окончания такого эффекта, и только потом показывает результат.
Из этого следует и польза, и цена. Польза: если внутри эффекта измерить элемент и сразу подвинуть, пользователь не увидит промежуточного состояния. Цена: пока эффект работает, отрисовка стоит, поэтому тяжёлые вычисления здесь блокируют интерфейс.
1const tooltipRef = useRef(null);
2const [position, setPosition] = useState({ top: 0, left: 0 });
3
4useLayoutEffect(() => {
5 const rect = tooltipRef.current.getBoundingClientRect();
6 // Подсказка не помещается снизу — показываем сверху. С обычным useEffect
7 // читатель увидел бы, как она прыгает.
8 if (rect.bottom > window.innerHeight) {
9 setPosition((prev) => ({ ...prev, top: prev.top - rect.height }));
10 }
11}, [anchor]);Правило: по умолчанию useEffect, а useLayoutEffect — только когда без него виден скачок. Типичные случаи: позиционирование подсказок и выпадающих меню, сохранение позиции прокрутки, измерение размеров перед анимацией.
Отдельная деталь: на сервере useLayoutEffect не выполняется — при серверном рендере React о нём предупредит. Если логика нужна только в браузере, это как раз тот случай, когда стоит проверить, а нельзя ли обойтись обычным эффектом.
useId: стабильные идентификаторы
Идентификаторы в разметке нужны, чтобы связать поле с подписью через htmlFor, или элемент с его описанием через aria-describedby. Придумывать их случайно нельзя: при серверном рендере разметка генерируется дважды — на сервере и в браузере, — и Math.random() даст разные значения. React увидит расхождение и пожалуется на несовпадение.
useId выдаёт идентификатор, одинаковый на сервере и на клиенте и уникальный для каждого экземпляра компонента:
1function Field({ label, hint, ...props }) {
2 const id = useId();
3
4 return (
5 <p>
6 <label htmlFor={id}>{label}</label>
7 <input id={id} aria-describedby={`${id}-hint`} {...props} />
8 <small id={`${id}-hint`}>{hint}</small>
9 </p>
10 );
11}Обратите внимание на приём: одного вызова useId хватает на несколько связанных идентификаторов — к нему добавляют суффиксы. Заводить по вызову на каждый элемент не нужно.
Чего useId не делает: он не годится в качестве ключа списка. Ключ должен быть привязан к данным, а не к месту в дереве.
Хуки, которые встретятся реже
Их стоит знать в лицо, чтобы узнать задачу, когда она появится.
useTransition и useDeferredValue — про приоритеты. Помечают обновление как необязательное, чтобы ввод в поле оставался отзывчивым, пока пересчитывается тяжёлый список. useTransition оборачивает само действие, useDeferredValue — значение.
useSyncExternalStore — мост к состоянию за пределами React: сторонний стор, localStorage, подписка на API браузера. Гарантирует, что компонент не покажет рассогласованные данные при конкурентном рендере. В прикладном коде встречается редко — обычно им пользуются библиотеки состояния.
useImperativeHandle — отдать родителю не DOM-узел, а собственный набор методов (focus, reset). Нужен при написании компонентов-обёрток над полями ввода.
useDebugValue — подпись для своего хука в React DevTools. Только для отладки.
Свои хуки
Самая недооценённая возможность: хуки можно писать самому. Свой хук — обычная функция, которая внутри вызывает другие хуки. Никакого специального API, только соглашение об имени с use.
Это способ переиспользовать логику с состоянием, а не разметку.
1function useToggle(initial = false) {
2 const [value, setValue] = useState(initial);
3 const toggle = useCallback(() => setValue((v) => !v), []);
4 return [value, toggle];
5}Важная деталь: два компонента, вызвавшие один хук, не делят состояние. Каждый вызов создаёт свой экземпляр — общий у них только код.
В примере ниже два своих хука: useLocalStorage синхронизирует состояние с хранилищем браузера, useDebounced откладывает значение. Перезагрузите превью — введённый текст останется:
Такой useDebounced — готовый рецепт для поиска: запрос уходит не на каждую букву, а когда пользователь остановился.
Что ломается чаще всего
Состояние меняют на месте. items.push(x) вместо нового массива — React не увидит изменения.
Забыли функцию очистки. Таймеры и подписки копятся при каждом перезапуске эффекта.
Соврали в зависимостях. Убрали значение из массива, чтобы эффект не дёргался, — получили эффект, работающий со старыми данными. Если линтер просит зависимость, обычно он прав; правильный ответ не в том, чтобы её убрать, а в том, чтобы перестроить эффект.
Состояние там, где хватило бы вычисления. Всё, что выводится из пропсов и другого состояния, считайте во время рендера.
Эффект вместо обработчика. Реакция на действие пользователя — это обработчик события, а не эффект. Эффекты нужны для синхронизации с внешним миром, а не для «сделать что-то после клика».
Хук под условием. Порядок вызовов должен совпадать от рендера к рендеру.
Куда двигаться дальше
Порядок освоения примерно такой. Сначала useState и useEffect — на них держится большая часть повседневной работы. Затем useRef и useContext: они появляются, как только приложение перестаёт быть одним экраном. Дальше useReducer — когда состояние обрастает переходами. useMemo и useCallback подключайте последними и по факту замеров, а не заранее.
Как только один и тот же кусок логики с состоянием появится в двух компонентах, вынесите его в свой хук. Это главный навык из всей темы: остальное — детали API, а умение резать логику на хуки определяет, во что превратится проект через полгода.
Лучший способ закрепить — писать код, а не читать про него. Примеры выше можно ломать и перебирать сколько угодно: у каждого свой проект, и перезапуск возвращает всё к исходному виду.
Если хочется не отдельных примеров, а последовательной практики с проверкой — этим занимаются курсы платформы: там те же хуки разбираются на задачах с автоматическими тестами.