Що таке кастомні хуки в React
Кастомні хуки - це JavaScript-функції з префіксом "use", які викликають вбудовані хуки React для спільного використання логіки зі станом у різних компонентах.
Теорія
TL;DR
- Кастомний хук схожий на рецепт у спільній книзі: написав
useState+useEffectодин раз, і будь-який компонент бере це без переписування - Головна різниця від звичайної функції: хук виконується під час фази рендеру React, тому стан і ефекти залишаються синхронізованими з компонентом
- Коли витягувати: якщо та сама хук-логіка з'являється в 2+ компонентах або виростає до ~20 рядків
- Якщо функція не викликає жодного хука - вона просто функція. Префікс "use" їй не потрібен.
Швидкий приклад
// До: логіка ресайзу дублюється в кожному компоненті
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>Ширина: {width}px</div>;
}
// Після: виносимо в хук, використовуємо де треба
function useWindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); // очищення при анмаунті
}, []);
return width;
}
function WindowSize() {
const width = useWindowSize(); // компонент залишається чистим
return <div>Ширина: {width}px</div>;
}Ось і весь патерн. Хук іде назовні, компонент спрощується.
Чим кастомний хук відрізняється від звичайної функції
Звичайну функцію можна викликати де завгодно: в циклах, умовах, колбеках. Кастомний хук - ні. React відстежує виклики хуків у фіксованому порядку всередині fiber-вузла кожного компонента через зв'язаний список станів та ефектів. Виклич хук умовно - список зміститься, і React прочитає не той стан з не того слота.
Саме тому важливий префікс "use". React бачить його і застосовує правила хуків: виклик тільки на верхньому рівні, тільки з компонентів або інших хуків. Функція без "use" не отримує такого контролю, а її внутрішній стан не збережеться між рендерами.
Коли використовувати
- Fetch + стани loading/error, що повторюються в кількох компонентах: кастомний хук
- Логіка валідації форми та відправки, що спільна для кількох сторінок: кастомний хук
- Синхронізація стану з localStorage: кастомний хук
- Разовий форматтер дати без стану: звичайна функція
- UI-анімація, яка є тільки в одному компоненті: залиш inline
Як React обробляє кастомні хуки внутрішньо
React обробляє кастомні хуки під час фази рендеру через той самий механізм, що й вбудовані хуки. Кожен виклик хука відповідає слоту в зв'язаному списку fiber-вузла через диспетчери mountState та updateState. Під час ре-рендеру React повторює виклики в тому ж порядку і відновлює стан з цих слотів. Жодної магії V8 тут немає - це reconciler React забезпечує порядок. Саме тому хуки вільно компонуються: useUser може викликати useFetch, який викликає useState і useEffect. Всі вони ділять один fiber компонента.
Типові помилки
Забули префікс "use":
function getWindowSize() { // React не застосовує правила хуків
const [width, setWidth] = useState(window.innerWidth); // стан не збережеться між рендерами
}Без префікса React сприймає функцію як звичайну і пропускає відстеження стану. Перейменуй на useWindowSize.
Виклик хука умовно:
function Component({ show }) {
if (show) {
const [data, setData] = useState(null); // порядок хуків змінюється при зміні show
}
}Це призводить до "Invalid hook call" або непередбачуваних багів зі станом. Завжди винось виклик хука на верхній рівень функції.
Stale closure (застарілий стан) у fetch-хуці:
function useAsyncTask(callback) {
const [result, setResult] = useState(null);
useEffect(() => {
callback().then(setResult);
}, [callback]); // callback має бути стабільним, інакше ефект спрацьовує щоразу
return result;
}
// Неправильно: callback відтворюється на кожному рендері
function BadComponent() {
const [count, setCount] = useState(0);
const result = useAsyncTask(async () => {
return count; // завжди повертає count з першого рендеру
});
}
// Правильно: стабілізуй через useCallback
const callback = useCallback(async () => {
return count;
}, [count]);
const result = useAsyncTask(callback);Це проблема номер один з кастомними хуками в продакшені. ESLint-правило react-hooks/exhaustive-deps ловить її автоматично.
Хук для тривіальної логіки:
function useDouble(n) { return n * 2; } // всередині немає хуків, немає сенсуЯкщо функція не викликає жодного хука - вона просто функція. Префікс "use" тут зайвий.
Де зустрічається
- TanStack Query:
useQueryкомпонуєuseEffect+useStateдля фетчингу з кешуванням - React Hook Form:
useFormінкапсулює весь стан форми та валідацію в один виклик - SWR (використовується в Next.js):
useSWRобробляє fetch, кеш і ревалідацію - Zustand:
useStoreяк легкий хук для глобального стану
Кастомні хуки замінили HOC (higher-order components) та render props для спільного використання логіки зі станом.
Питання на співбесіді
Q: Чому ім'я кастомного хука має починатись з "use"?
A: React шукає префікс "use" під час рендеру, щоб застосувати правила хуків. Без нього функція вважається звичайною, стан не зберігається між рендерами, а ефекти не спрацьовують коректно.
Q: Чи може кастомний хук викликати інші кастомні хуки?
A: Так. Хуки вільно компонуються. useUser може викликати useFetch, який викликає useState і useEffect. Всі вони ділять fiber одного компонента.
Q: Що станеться, якщо викликати кастомний хук всередині колбека?
A: React викине "Invalid hook call". Хуки мають викликатись тільки на верхньому рівні функціонального компонента або іншого хука, але не всередині обробників подій чи async-функцій.
Q: В чому різниця між кастомним хуком і useReducer?
A: useReducer управляє складним локальним станом в одному компоненті. Кастомний хук упаковує логіку стану так, щоб її могли спільно використовувати кілька компонентів.
Q: Поясни баг зі stale closure у fetch-хуці (рівень senior).
A: Якщо масив залежностей useEffect не містить пропсу, наприклад userId, ефект захоплює значення з першого рендеру. Коли компонент отримає новий userId, ефект не перезапуститься і відобразяться дані для старого користувача. Рішення: включи всі залежності в масив або стабілізуй колбек через useCallback.
Приклади
Базовий: відстеження ширини вікна
function useWindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); // очищення при анмаунті
}, []);
return width;
}
function Header() {
const width = useWindowSize();
return <nav>{width < 768 ? <MobileMenu /> : <DesktopMenu />}</nav>;
}Один хук, багато компонентів. Будь-який компонент, якому потрібна ширина вікна, викликає useWindowSize() замість того, щоб заново описувати логіку слухача.
Середній: фетчинг даних зі станами loading і error
// useUser.js - використовується в дашбордах, профілях, адмін-панелях
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]); // автоматично перефетчить при зміні userId
return { user, loading, error };
}
function Profile({ userId }) {
const { user, loading, error } = useUser(userId);
if (loading) return <div>Завантаження...</div>;
if (error) return <div>Не вдалось завантажити користувача</div>;
return <div>{user.name}</div>;
}Компонент відповідає тільки за відображення. Вся логіка фетчингу живе в хуку. Я бачив, як відсутній userId у масиві залежностей коштував кількох годин дебагу в продакшені: UI показував дані старого користувача при переключенні між профілями. Один запис у dependency array вирішує це.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.