Skip to main content

Що таке кастомні хуки в React

Кастомні хуки - це JavaScript-функції з префіксом "use", які викликають вбудовані хуки React для спільного використання логіки зі станом у різних компонентах.

Теорія

TL;DR

  • Кастомний хук схожий на рецепт у спільній книзі: написав useState + useEffect один раз, і будь-який компонент бере це без переписування
  • Головна різниця від звичайної функції: хук виконується під час фази рендеру React, тому стан і ефекти залишаються синхронізованими з компонентом
  • Коли витягувати: якщо та сама хук-логіка з'являється в 2+ компонентах або виростає до ~20 рядків
  • Якщо функція не викликає жодного хука - вона просто функція. Префікс "use" їй не потрібен.

Швидкий приклад

jsx
// До: логіка ресайзу дублюється в кожному компоненті 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":

jsx
function getWindowSize() { // React не застосовує правила хуків const [width, setWidth] = useState(window.innerWidth); // стан не збережеться між рендерами }

Без префікса React сприймає функцію як звичайну і пропускає відстеження стану. Перейменуй на useWindowSize.

Виклик хука умовно:

jsx
function Component({ show }) { if (show) { const [data, setData] = useState(null); // порядок хуків змінюється при зміні show } }

Це призводить до "Invalid hook call" або непередбачуваних багів зі станом. Завжди винось виклик хука на верхній рівень функції.

Stale closure (застарілий стан) у fetch-хуці:

jsx
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 ловить її автоматично.

Хук для тривіальної логіки:

jsx
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.

Приклади

Базовий: відстеження ширини вікна

jsx
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

jsx
// 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 вирішує це.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?