Правила використання хуків у React
Правила використання хуків у React визначають де і як можна викликати функції на зразок useState та useEffect. Порушиш їх і React втрачає зв'язок між станом і компонентом.
Теорія
TL;DR
- Хуки викликаються лише на верхньому рівні компонента або кастомного хука, ніколи всередині умов, циклів чи вкладених функцій
- Хуки можна викликати лише з функціональних компонентів або кастомних хуків (функцій з префіксом
use) - React відстежує хуки за позицією виклику, а не за іменем. Однаковий порядок кожен рендер = стабільний стан
- Аналогія: ресторан готує страви у фіксованій послідовності. Пропусти один крок і кухня загубить твоє замовлення
- Правило вибору: спочатку всі виклики хуків, умови і цикли - нижче
Швидкий приклад
// ПОГАНО: умовний виклик хука ламає порядок
function BadComponent({ showCount }: { showCount: boolean }) {
if (showCount) {
const [count, setCount] = useState(0); // React губить відстеження слотів
}
return <div>{showCount ? count : 'Приховано'}</div>;
}
// ДОБРЕ: хук завжди викликається, умова - після
function GoodComponent({ showCount }: { showCount: boolean }) {
const [count, setCount] = useState(0); // Слот 1 - завжди викликається
return <div>{showCount ? count : 'Приховано'}</div>;
}BadComponent кидає "Rendered more hooks than during the previous render". GoodComponent працює стабільно при кожному рендері.
Чому порядок виклику важливий
React зберігає стан хуків у зв'язаному списку (linked list), прикріпленому до fiber-вузла кожного компонента. При першому рендері будується цей список: слот 1, слот 2, слот 3. При кожному наступному рендері React проходить по ньому в тому ж порядку і відновлює кожне значення. Якщо умова пропускає слот 1, слот 2 отримує дані з слота 1 і все зсувається. Правило не є довільним обмеженням. Це пряме наслідування архітектури fiber у React.
Коли використовувати
- Потрібен стан або side effect у компоненті - додай хук на верхньому рівні, до будь-якого
return - Одна й та сама логіка зі станом у кількох компонентах - винеси в кастомний хук
useMyLogic, не в звичайну функцію - Потрібна умовна логіка - помісти умову всередину виклику хука або після всіх хуків
- Цикли - виклик одного хука поза циклом, значення зберігай у масиві або об'єкті з ключем-індексом
- Обробники подій - визначай їх всередині компонента, але ніколи не викликай хуки в тілі самого обробника
Ранній return дозволений. if (!userId) return null після викликів useState - валідний React 18 код. Хуки просто мають завершитись до будь-яких умовних виходів.
Як React виявляє порушення
Внутрішній диспетчер React перемикається між фазами mount і update при кожному рендері. Під час update він перевіряє, що кількість викликів хуків збігається з попереднім рендером. Невідповідність одразу призводить до помилки з ReactCurrentDispatcher. eslint-plugin-react-hooks перехоплює більшість порушень статично, ще до запуску, тому його включають у стандартне налаштування будь-якого React-проєкту.
Типові помилки
Хук всередині умови
if (isLoggedIn) {
const [user, setUser] = useState(null); // зсуває всі наступні слоти
}Виправлення: спочатку виклик хука без умов, перевірку isLoggedIn - нижче.
Хук всередині обробника події
function handleClick() {
const [local, setLocal] = useState(0); // звичайна JS-функція, React її не відстежує
}Виправлення: перенеси стан на рівень компонента. Для мемоізації обробника використовуй useCallback.
Хук всередині циклу
items.map(item => {
const [active, setActive] = useState(false); // нові екземпляри при кожному рендері
return <li>{active ? 'увімк' : 'вимк'}</li>;
});Виправлення: один useState вгорі, значення зберігай в об'єкті з ключем-id елемента.
Кастомний хук без префікса use
function myFetchLogic() {
const data = useSWR('/api/data'); // ESLint не відстежить порушення всередині
}Виправлення: перейменуй у useMyFetchLogic. Префікс use сигналізує і React, і ESLint, що це хук.
Хук поза компонентом
const globalCount = useState(0); // область модуля, не відстежуєтьсяВиправлення: перенеси всередину компонента або кастомного хука.
Де зустрічається у реальних проєктах
- React:
useStateіuseEffectу кожному функціональному компоненті починаючи з React 16.8 - Next.js:
useSWRвід Vercel для отримання даних у app router - Redux Toolkit:
useSelectorіuseDispatchу підключених компонентах - TanStack Query:
useQueryіuseMutation, понад 2 мільйони завантажень на тиждень - Zustand:
useStoreдля легковагового глобального стану
На практиці помилку "хук всередині .map()" найчастіше бачу в код-рев'ю команд, що мігрують з класових компонентів. Виправлення завжди одне: один хук, один масив поза циклом.
Follow-up питання
Q: Чому React відстежує хуки за порядком виклику, а не за іменем або ID?
A: Імена не унікальні. Два виклики useState в одному компоненті зіштовхнулись би. Позиція виклику дає унікальний стабільний слот без додаткових метаданих.
Q: Чи можна викликати хук після раннього return?
A: Ні. Хук після return пропускається при цьому рендері і кількість слотів не збігається. Всі хуки повинні відпрацювати до будь-яких умовних виходів.
Q: Що таке кастомний хук і коли його варто створювати?
A: Функція з префіксом use, яка викликає інші хуки всередині. Створюй, коли одна й та сама логіка зі станом повторюється у двох або більше компонентах.
Q: Як eslint-plugin-react-hooks визначає де викликаються хуки?
A: Він аналізує AST і позначає будь-який виклик функції з use всередині умови, циклу або функції, яка не є компонентом. Також перевіряє наявність префікса use у кастомних хуків.
Q (senior): Що відбувається у fiber React, коли порядок хуків змінюється між рендерами?
A: Кожен fiber зберігає зв'язаний список memoizedState. При оновленні React читає вузли послідовно. Відсутній вузол означає, що поточний вузол містить дані з попереднього. Всі наступні читання зсунуті на один слот. React виявляє невідповідність кількості через ReactCurrentDispatcher і кидає помилку до повернення некоректного стану.
Приклади
Лічильник з двома слотами стану
function Counter() {
const [count, setCount] = useState(0); // Слот 1
const [label, setLabel] = useState('кліків'); // Слот 2 - завжди викликається
return (
<div>
<p>{count} {label}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
// Виведення: стабільні count і label між рендерами. Слоти ніколи не зсуваються.Обидва хуки викликаються при кожному рендері. React відновлює count у слот 1 і label у слот 2 без плутанини.
Список задач з фільтрацією
function TodoList({ todos }: { todos: { id: number; text: string; done: boolean }[] }) {
const [filter, setFilter] = useState<'all' | 'active' | 'done'>('all'); // Слот 1
const visibleTodos = useMemo(
() => filter === 'all' ? todos : todos.filter(t => filter === 'done' ? t.done : !t.done),
[todos, filter]
); // Слот 2
return (
<div>
<select onChange={e => setFilter(e.target.value as any)} value={filter}>
<option value="all">всі</option>
<option value="active">активні</option>
<option value="done">виконані</option>
</select>
<ul>{visibleTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
</div>
);
}
// Виведення: стан фільтра зберігається між рендерами. Memo пропускає перерахунок якщо вхідні дані не змінились.useState у слоті 1, useMemo у слоті 2, жодних умов навколо викликів. Логіка фільтрації - всередині useMemo, а не навколо самого хука.
Розширений: ранній return після хуків
function Profile({ userId }: { userId: string | null }) {
const [profile, setProfile] = useState(null); // Слот 1 - виконується при кожному рендері
const [posts, setPosts] = useState([]); // Слот 2 - виконується при кожному рендері
if (!userId) return <div>Користувача не вибрано</div>; // ранній return тут дозволений
// логіка завантаження і рендерингу коли userId є
return <div>Профіль {userId}</div>;
}
// Виведення: кількість слотів не змінюється, бо обидва useState відпрацьовують до умовного виходу.Обидва useState виконуються до перевірки if (!userId), тому React завжди рахує два слоти. Поміняй порядок і правило буде порушено одразу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.