Хуки в React: правила, кастомные хуки и useEffect
До 2019 года логику в React можно было переиспользовать только через сложные конструкции вроде render-пропсов и компонентов высшего порядка. Это превращало компоненты в трёхуровневые вложения ради одного общего кусочка поведения. Хуки решили эту проблему: теперь любую логику с состоянием можно вынести в обычную функцию и звать её из любого компонента.
Правила хуков
Хуки — это функции, имя которых начинается с use. React знает о них благодаря двум жёстким правилам.
Первое правило: только на верхнем уровне. Хук нельзя вызывать внутри условия, цикла или вложенной функции.
// неверно: порядок вызовов «поплывёт» при каждом рендере
if (isOpen) {
const [value, setValue] = useState("");
}
// верно: хук вызывается всегда, условие — внутри
const [value, setValue] = useState("");
if (isOpen) {
// используем value
}
React запоминает состояние каждого хука по его порядковому номеру в списке вызовов. Если порядок меняется от рендера к рендеру, React теряет, какое состояние принадлежит какому хуку.
Второе правило: только из React-функций. Хуки вызывают из компонентов и из других кастомных хуков — не из обычных вспомогательных функций.
Линтер-плагин eslint-plugin-react-hooks ловит нарушения обоих правил. Его держат включённым и не «затыкают» предупреждения.
Кастомные хуки
Кастомный хук — это обычная функция с именем use..., внутри которой вызываются другие хуки. Она ничем не отличается технически, но позволяет упаковать повторяющуюся логику в одно место.
Например, задержка перед поиском (дебаунс) нужна в нескольких местах приложения. Без хука этот useEffect придётся копировать в каждый компонент. С хуком:
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
Теперь компонент просто пользуется хуком, не зная о деталях:
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounced(query, 300);
// debouncedQuery обновится не сразу, а через 300 мс после ввода
}
Кастомные хуки хорошо компонуются: один может вызывать другой. Логика не дублируется, компонент остаётся про отображение.
useEffect: для чего он на самом деле
useEffect позволяет синхронизировать компонент с чем-то внешним: подпиской, таймером, ручной работой с DOM, внешним хранилищем. Вот типичный пример — подписка на событие браузера:
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []); // пустой массив — эффект запустится один раз при монтировании
Функция, которую возвращает эффект — это очистка. Она вызывается перед следующим запуском эффекта и при размонтировании компонента. Без очистки подписки и таймеры накапливаются.
Очистка выполняется перед каждым повторным запуском и в самом конце: подписка и таймер никогда не остаются без пары.
Зависимости: почему массив важен
Второй аргумент useEffect — массив зависимостей. React запускает эффект заново, когда хоть одно значение из массива изменилось.
useEffect(() => {
document.title = `Привет, ${name}`;
}, [name]); // эффект запустится снова, только если name изменился
Если забыть указать переменную в массиве, эффект будет работать с устаревшим значением. Линтер предупреждает об этом — и его правке стоит доверять, а не добавлять // eslint-disable-line вручную.
Гонки при загрузке данных
Если компонент загружает данные в useEffect и пользователь быстро меняет входные данные, может прийти ответ на старый запрос поверх уже полученного свежего. Это называется гонкой запросов.
Стандартная защита — флаг отмены в функции очистки:
useEffect(() => {
let cancelled = false;
fetchUser(userId).then((user) => {
if (!cancelled) setUser(user);
});
return () => {
cancelled = true;
};
}, [userId]);
Когда userId меняется, React запускает очистку предыдущего эффекта (cancelled = true) прежде, чем запустить следующий. Старый ответ придёт, но не попадёт в состояние.
Когда useEffect не нужен
Самая частая ошибка — использовать useEffect там, где он не нужен. Три таких случая:
Производное значение. Если новое значение вычисляется из уже имеющихся данных, просто считайте его при рендере:
// не нужен эффект
const fullName = firstName + " " + lastName;
Эффект с useState сделает то же самое, но с лишним рендером и лишним кодом.
Реакция на событие. Если что-то должно произойти в ответ на нажатие кнопки или отправку формы — поместите логику в обработчик события, а не в эффект, который следит за изменением состояния.
Загрузка серверных данных. Ручной useEffect для запроса данных с сервера — это примерно десять строк кода с неполной защитой от гонок, без кэширования и без состояния загрузки. Библиотеки вроде TanStack Query решают эту задачу надёжно и компактно.
Простой вопрос перед написанием эффекта: «это синхронизация с внешним миром или реакция на собственное состояние?» Если второе — эффект не нужен.
Глубже: жизненный цикл, useLayoutEffect и редкие хукирасширенное
Вопрос про жизненный цикл компонента остался в собеседованиях со времён классов, и на него есть короткий перевод в хуки. Монтирование это первый рендер плюс эффекты с пустым массивом зависимостей, обновление это повторный рендер плюс эффекты, чьи зависимости изменились, размонтирование это функции очистки всех эффектов. componentDidMount и componentDidUpdate стали одним useEffect, componentWillUnmount стал его возвращаемой функцией, а shouldComponentUpdate превратился в React.memo.
useLayoutEffect отличается от useEffect только моментом запуска: он выполняется синхронно после изменения DOM и до того, как браузер нарисует кадр. Поэтому в нём измеряют размеры и позиции и сразу двигают элемент, чтобы пользователь не увидел скачок; всё остальное, запросы, подписки, логирование, живёт в useEffect, который кадр не задерживает. На сервере useLayoutEffect не выполняется и предупреждает об этом.
Три хука, о которых спрашивают отдельно. useRef хранит значение между рендерами, не вызывая перерисовку при изменении: ссылка на DOM-узел, идентификатор таймера, предыдущее значение пропса; обращаться к DOM через ref правильнее, чем через querySelector, потому что ссылка привязана к конкретному экземпляру компонента и исчезает вместе с ним. useReducer заменяет useState, когда следующее состояние зависит от предыдущего и от типа действия, например в форме с десятком связанных полей. useId даёт стабильный идентификатор для связки label и input, одинаковый на сервере и клиенте, чтобы не ломать гидрацию. Про useTransition и useDeferredValue, которые помечают обновление несрочным, рассказано в статье как React рисует.
Глубже: React 19 и компиляторрасширенное
Хуки не стоят на месте. Три вещи из React 19 и его окружения, которые меняют повседневный код:
refкак обычный проп. В функциональных компонентахrefприходит в props, иforwardRefбольше не нужен:
function Input({ ref, ...props }: React.ComponentProps<"input">) {
return <input ref={ref} {...props} />;
}
use(promise)читает промис прямо во время рендера и приостанавливает компонент до ответа, а ближайшийSuspenseпоказывает заглушку. В отличие от хуков,useможно звать внутри условий. Это не замена библиотекам запросов: кэш и инвалидация по-прежнему на них.- React Compiler мемоизирует компоненты и вычисления сам: ручные
useMemoиuseCallback«на всякий случай» перестают быть нормой и остаются точечной оптимизацией там, где компилятор не справился. Компилятор полагается на правила React: код, который их нарушает (мутация пропсов, хук в условии), он пропускает без оптимизации, а свежийeslint-plugin-react-hooksтакие места подсвечивает.
Проверочный вопрос к существующему коду: сколько в нём useCallback, пользу которых никто не измерял? После компилятора их убирают по мере того, как трогают файл.
Коротко
- Хуки — функции с именем
use..., которые позволяют использовать состояние и побочные эффекты в функциональных компонентах. - Два правила: вызывать только на верхнем уровне и только из React-функций. Нарушение ломает порядок вызовов.
- Кастомный хук упаковывает повторяющуюся логику в одну функцию — без дублирования по компонентам.
useEffectнужен для синхронизации с внешним миром: подписки, таймеры, DOM. Не для реакции на собственное состояние.- Функция очистки в
useEffectобязательна, если создаёте подписку или таймер — иначе утечки. - Массив зависимостей должен включать всё, что эффект читает. Линтер помогает это контролировать.
- При загрузке данных в эффекте защищайтесь от гонок через флаг отмены.
- Большинство
useEffectу новичков — лишние: производные значения считают при рендере, реакцию на события кладут в обработчики. - Жизненный цикл в хуках: монтирование это эффекты с пустым массивом, обновление это эффекты с изменившимися зависимостями, размонтирование это очистка;
useLayoutEffectдо кадра для измерений,useRefдля значения без перерисовки,useReducerдля связанных переходов,useIdдля идентификаторов, одинаковых на сервере и клиенте.
Что почитать дальше
- Компоненты и пропсы — как устроена основная единица React.
- Состояние: локальное и серверное — какие данные стоит хранить в
useState, а какие — в библиотеке. - Загрузка данных — TanStack Query как замена ручному
useEffectдля запросов.