Кастомні хуки: повторне використання та композиція реактивної логіки
Кастомні хуки: повторне використання та композиція реактивної логіки
🔗 Офіційний референс: Повторне використання логіки з кастомними хуками (Reusing Logic with Custom Hooks)
🎯 Мета лекції
- Зрозуміти філософію кастомних хуків як абстракції для повторного використання поведінки.
- Опанувати правила іменування, композиції та ізоляції стану між викликами хуків.
- Навчитися створювати типізовані кастомні хуки для типових сценаріїв (fetch, localStorage, debounce).
- Застосувати архітектурні патерни для організації хуків у великих проєктах.
🔑 Ключові терміни
- Кастомний хук (Custom Hook): TypeScript-функція, що починається з префікса
useта може викликати інші хуки React. - Ізоляція стану (State Isolation): кожен виклик кастомного хука отримує власний незалежний екземпляр стану.
- Композиція хуків (Hook Composition): побудова складних хуків шляхом комбінування простіших хуків.
- Інверсія контролю (Inversion of Control): компонент не знає, як реалізована логіка хука — він просто отримує результат.
Чому існують кастомні хуки?
У попередніх лекціях ми вивчили вбудовані хуки React: useState, useEffect, useRef, useReducer, useContext. Вони дозволяють компонентам мати стан, синхронізуватися із зовнішніми системами та обмінюватися даними.
Проте, коли проєкт росте, ви починаєте помічати повторювані патерни реактивної логіки:
- Відстеження статусу мережевого з'єднання (
navigator.onLine). - Завантаження даних з API із обробкою станів
loading,error,data. - Збереження стану у
localStorageіз автоматичною синхронізацією. - Відкладений (debounced) виклик функції при введенні тексту.
- Анімації, таймери, підписки на браузерні події.
Якщо копіювати цю логіку у кожен компонент, де вона потрібна, код стане громіздким, важко тестованим та схильним до помилок.
Правила створення кастомних хуків
React накладає лише дві суворі вимоги на кастомні хуки:
Правило 1: Ім'я функції завжди починається з префікса use
// ✅ ПРАВИЛЬНО
function useOnlineStatus() { ... }
function useFetch(url: string) { ... }
function useDebounce<T>(value: T, delay: number) { ... }
// ❌ НЕПРАВИЛЬНО
function getOnlineStatus() { ... } // Не починається з use
function fetchData(url: string) { ... } // Не починається з use
Префікс use — це не конвенція, а технічна вимога. Лінтер React Hooks перевіряє, що функції, які починаються з use, дотримуються правил хуків (виклик лише на верхньому рівні, не всередині умов/циклів). Без цього префікса лінтер не зможе валідувати ваш код.
Правило 2: Хук може викликати інші хуки React
Всередині кастомного хука можна вільно використовувати:
- Вбудовані хуки:
useState,useEffect,useRef,useMemo,useCallback,useContext, тощо. - Інші кастомні хуки:
useOnlineStatus,useFetch,useLocalStorage.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine); // ✅ useState
useEffect(() => { // ✅ useEffect
function handleOnline() { setIsOnline(true); }
function handleOffline() { setIsOnline(false); }
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}
- ❌ Не можна викликати хуки всередині умов, циклів або вкладених функцій.
- ❌ Не можна викликати хуки після раннього повернення (
return). - ✅ Хуки завжди мають викликатися у фіксованому порядку при кожному рендері.
Базовий приклад: useOnlineStatus — відстеження мережевого з'єднання
Почнемо з найпростішого прикладу — хука, що підписується на браузерні події online та offline для відстеження статусу з'єднання.
Реалізація хука
import { useState, useEffect } from "react";
export function useOnlineStatus(): boolean {
// Початковий стан — поточний статус з'єднання
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
useEffect(() => {
// Обробники подій
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
// Підписка на події window
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Очищення підписок
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []); // Порожній масив — підписка створюється один раз
return isOnline;
}
Використання у компоненті
function StatusIndicator() {
const isOnline = useOnlineStatus(); // Простий виклик хука
return (
<div className={`status ${isOnline ? "online" : "offline"}`}>
{isOnline ? "🟢 Онлайн" : "🔴 Офлайн"}
</div>
);
}
- Інкапсуляція: Логіка роботи з
navigator.onLineта браузерними подіями прихована всередині хука. Компонент не знає деталей реалізації. - Повторне використання: Будь-який компонент може викликати
useOnlineStatus()— не потрібно копіювати код підписок. - Тестованість: Хук можна протестувати ізольовано, не рендерячи компонент.
- Читабельність: Компонент виражає намір на високому рівні абстракції: «покажи статус з'єднання», а не «підпишись на події, оновлюй стан, не забудь відписатися».
Ізоляція стану: кожен виклик хука має власний стан
Критично важливо зрозуміти: кастомні хуки ділять логіку, але не ділять стан. Кожен виклик хука отримує повністю незалежний екземпляр стану.
function Dashboard() {
const isOnline1 = useOnlineStatus(); // Власний стан isOnline
const isOnline2 = useOnlineStatus(); // Інший незалежний стан isOnline
// isOnline1 та isOnline2 — це дві різні змінні стану
// Вони будуть мати однакове значення (true/false), бо обидві
// читають navigator.onLine, але це окремі виклики useState всередині
return (
<div>
<p>Індикатор 1: {isOnline1 ? "Онлайн" : "Офлайн"}</p>
<p>Індикатор 2: {isOnline2 ? "Онлайн" : "Офлайн"}</p>
</div>
);
}
Якщо потрібно ділитися станом між компонентами, використовуйте:
- Context API (
useContext) для глобального стану. - Підйом стану (lifting state up) до спільного батьківського компонента.
- Зовнішні стор-менеджери (Redux, Zustand), які ми вивчимо у наступних лекціях.
Типізований хук завантаження даних: useFetch
Один із найпоширеніших сценаріїв — завантаження даних з API. Створимо узагальнений (generic) хук із підтримкою TypeScript та скасування запитів через AbortController.
Реалізація useFetch з повною типізацією
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
isLoading: boolean;
error: string | null;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Створюємо AbortController для скасування запиту
const controller = new AbortController();
const { signal } = controller;
async function fetchData(): Promise<void> {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP помилка ${response.status}: ${response.statusText}`);
}
const result: T = await response.json();
setData(result);
} catch (err) {
// Ігноруємо AbortError — це нормальне скасування
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
} finally {
// Оновлюємо isLoading лише якщо запит не було скасовано
if (!signal.aborted) {
setIsLoading(false);
}
}
}
fetchData();
// Функція очищення: скасовує запит при розмонтуванні або зміні URL
return () => {
controller.abort();
};
}, [url]); // Ефект перезапускається при зміні URL
return { data, isLoading, error };
}
Використання useFetch у компоненті
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useFetch<User>(
`https://api.example.com/users/${userId}`
);
if (isLoading) {
return <div className="spinner">Завантаження...</div>;
}
if (error) {
return <div className="error">Помилка: {error}</div>;
}
if (!user) {
return <div>Користувача не знайдено</div>;
}
return (
<div className="user-card">
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
useFetch краще за звичайний useEffect у компоненті:- DRY-принцип: Логіка
fetchне дублюється у кожному компоненті. - Безпека:
AbortControllerавтоматично скасовує застарілі запити — запобігає race conditions. - Типізація: TypeScript знає структуру
dataзавдяки дженерику<User>. - Стандартизація: Усі компоненти обробляють стани
loading,error,dataоднаково.
Хук синхронізації з localStorage: useLocalStorage
Створимо хук, що зберігає стан у localStorage та автоматично синхронізує його між вкладками браузера.
Реалізація useLocalStorage
import { useState, useEffect } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Ініціалізація стану: спочатку читаємо з localStorage
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.error(`Помилка читання з localStorage (ключ: ${key}):`, error);
return initialValue;
}
});
// Функція оновлення стану з записом у localStorage
const setValue = (value: T | ((prev: T) => T)) => {
try {
// Підтримка функціонального оновлення
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
// Записуємо у localStorage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Помилка запису у localStorage (ключ: ${key}):`, error);
}
};
// Синхронізація між вкладками браузера
useEffect(() => {
function handleStorageChange(event: StorageEvent) {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue) as T);
} catch (error) {
console.error("Помилка парсингу події storage:", error);
}
}
}
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key]);
return [storedValue, setValue];
}
Використання useLocalStorage
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
function toggleTheme() {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
}
return (
<div className={`app ${theme}`}>
<button onClick={toggleTheme}>
{theme === "light" ? "🌙 Темна тема" : "☀️ Світла тема"}
</button>
</div>
);
}
storage автоматично спрацьовує в усіх інших відкритих вкладках того самого домену. Хук підписується на цю подію і оновлює стан — тема змінюється у всіх вкладках миттєво, без запитів до сервера.Хук відкладеного виклику: useDebounce
При введенні тексту у пошукове поле не варто викликати API після кожного натискання клавіші — це створить сотні зайвих запитів. Debounce відкладає виконання функції до моменту, коли користувач припинить вводити текст на певний час.
Реалізація useDebounce
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// Створюємо таймер, що оновить debouncedValue через delay мілісекунд
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Очищаємо таймер при кожній зміні value (користувач продовжує вводити)
return () => {
clearTimeout(timerId);
};
}, [value, delay]);
return debouncedValue;
}
Використання useDebounce для пошуку
function SearchBar() {
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebounce(searchQuery, 500); // 500мс затримка
const { data: results, isLoading } = useFetch<string[]>(
debouncedQuery
? `https://api.example.com/search?q=${debouncedQuery}`
: ""
);
return (
<div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введіть запит..."
/>
{isLoading && <p>Пошук...</p>}
{results && (
<ul>
{results.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
)}
</div>
);
}
Що відбувається:
- Користувач вводить
"r","re","rea"швидко (менше 500мс між натисканнями). - Кожне введення скидає таймер через
clearTimeout. - Коли користувач припиняє вводити на 500мс, таймер спрацьовує і оновлює
debouncedQuery. useFetchбачить змінуdebouncedQueryі виконує один запит до API.
Результат: Замість 3 запитів (r, re, rea) — лише 1 запит (rea).
Композиція хуків: побудова складних хуків із простих
Одна з найпотужніших властивостей кастомних хуків — їхня композиційність (composability). Складні хуки можна будувати, комбінуючи простіші хуки.
Приклад: useSearchWithDebounce — композиція useDebounce та useFetch
interface UseSearchResult<T> {
query: string;
setQuery: (value: string) => void;
results: T | null;
isLoading: boolean;
error: string | null;
}
export function useSearchWithDebounce<T>(
baseUrl: string,
delay: number = 500
): UseSearchResult<T> {
const [query, setQuery] = useState("");
// Композиція хука useDebounce
const debouncedQuery = useDebounce(query, delay);
// Композиція хука useFetch
const { data: results, isLoading, error } = useFetch<T>(
debouncedQuery ? `${baseUrl}?q=${debouncedQuery}` : ""
);
return {
query,
setQuery,
results,
isLoading,
error,
};
}
Використання композитного хука
interface SearchResult {
items: string[];
total: number;
}
function SearchComponent() {
const { query, setQuery, results, isLoading, error } =
useSearchWithDebounce<SearchResult>("https://api.example.com/search");
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Пошук..."
/>
{isLoading && <p>Пошук...</p>}
{error && <p className="error">{error}</p>}
{results && (
<div>
<p>Знайдено: {results.total}</p>
<ul>
{results.items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
)}
</div>
);
}
- Модульність:
useSearchWithDebounceпобудовано з двох незалежних хуків (useDebounce+useFetch), які можна використовувати окремо. - Тестованість: Кожен простий хук можна протестувати ізольовано. Тест складного хука може покладатися на перевірені прості хуки.
- Підтримуваність: Якщо логіка
useFetchзмінюється (наприклад, додаємо кешування), усі композитні хуки автоматично отримають це покращення.
Хук відстеження розміру вікна: useWindowSize
Ще один корисний приклад — хук для responsive-дизайну, що відстежує ширину та висоту вікна браузера.
Реалізація useWindowSize
import { useState, useEffect } from "react";
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Підписка на зміну розміру вікна
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return windowSize;
}
Використання для адаптивного UI
function ResponsiveLayout() {
const { width } = useWindowSize();
// Визначаємо breakpoints
const isMobile = width < 768;
const isTablet = width >= 768 && width < 1024;
const isDesktop = width >= 1024;
return (
<div className="layout">
{isMobile && <MobileNavigation />}
{isTablet && <TabletNavigation />}
{isDesktop && <DesktopNavigation />}
<main>
<p>Ширина вікна: {width}px</p>
<p>Пристрій: {isMobile ? "Мобільний" : isTablet ? "Планшет" : "Десктоп"}</p>
</main>
</div>
);
}
resize може спрацьовувати десятки разів за секунду при перетягуванні вікна. Для продакшн-застосунків рекомендується комбінувати useWindowSize із useDebounce або використовувати throttle (обмеження частоти викликів).function useWindowSizeOptimized(): WindowSize {
const rawSize = useWindowSize();
const debouncedSize = useDebounce(rawSize, 200); // Затримка 200мс
return debouncedSize;
}
Практичні завдання для закріплення
Створіть хук useAutoSave, що автоматично зберігає значення поля форми у localStorage через 2 секунди після останнього введення (debounce).
Вимоги:
- Хук приймає
key: string(ключ для localStorage) таinitialValue: string. - Повертає кортеж
[value, setValue, isSaving], деisSavingвказує, чи відбувається збереження. - Використовує композицію
useLocalStorageтаuseDebounce.
Сигнатура:
function useAutoSave(
key: string,
initialValue: string
): [string, (value: string) => void, boolean] {
// Ваша реалізація
}
import { useState, useEffect } from "react";
function useAutoSave(
key: string,
initialValue: string
): [string, (value: string) => void, boolean] {
// Стан поточного значення (змінюється при кожному введенні)
const [value, setValue] = useState<string>(() => {
const stored = localStorage.getItem(key);
return stored !== null ? stored : initialValue;
});
// Відкладене значення (оновлюється через 2 секунди після останнього введення)
const [debouncedValue, setDebouncedValue] = useState<string>(value);
// Індикатор збереження
const [isSaving, setIsSaving] = useState<boolean>(false);
// Debounce для value
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedValue(value);
}, 2000);
return () => clearTimeout(timerId);
}, [value]);
// Збереження у localStorage при зміні debouncedValue
useEffect(() => {
if (debouncedValue !== value) {
return; // Якщо значення ще не debounced — не зберігаємо
}
setIsSaving(true);
// Імітація асинхронного збереження (у реальному застосунку — fetch)
const saveTimer = setTimeout(() => {
localStorage.setItem(key, debouncedValue);
console.log(`✅ Збережено у localStorage: ${key} = "${debouncedValue}"`);
setIsSaving(false);
}, 500);
return () => clearTimeout(saveTimer);
}, [debouncedValue, key, value]);
return [value, setValue, isSaving];
}
// Використання
function AutoSaveForm() {
const [text, setText, isSaving] = useAutoSave("draft", "");
return (
<div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Введіть текст..."
rows={5}
style={{ width: "100%", padding: "0.5rem" }}
/>
{isSaving && <p className="saving-indicator">💾 Збереження...</p>}
{!isSaving && text && <p className="saved-indicator">✅ Збережено</p>}
</div>
);
}
Покрокове пояснення:
useState(value)— основний стан, що оновлюється при кожному натисканні клавіші.useState(debouncedValue)— відкладений стан, що оновлюється через 2 секунди після останнього введення.- Перший
useEffect— створює таймер при кожній змініvalue. Якщо користувач продовжує вводити (таймер скидається),debouncedValueне оновлюється. - Другий
useEffect— спрацьовує, колиdebouncedValueзмінюється. Це означає, що користувач припинив вводити на 2 секунди. Тоді відбувається збереження уlocalStorage. isSaving— індикатор стану збереження для відображення спінера у UI.
Створіть хук usePolling, що виконує HTTP-запит до API кожні N секунд (polling) і автоматично зупиняється при розмонтуванні компонента.
Вимоги:
- Хук приймає
url: stringтаintervalMs: number. - Повертає
{ data, isLoading, error }. - Перший запит виконується негайно, наступні — через інтервал.
- При зміні
urlабоintervalMsтаймер перестворюється.
Сигнатура:
function usePolling<T>(url: string, intervalMs: number): {
data: T | null;
isLoading: boolean;
error: string | null;
} {
// Ваша реалізація
}
import { useState, useEffect, useRef } from "react";
function usePolling<T>(url: string, intervalMs: number): {
data: T | null;
isLoading: boolean;
error: string | null;
} {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Реф для зберігання функції fetch (уникаємо застарілих замикань)
const fetchDataRef = useRef<() => Promise<void>>();
fetchDataRef.current = async () => {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: T = await response.json();
setData(result);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Перший запит негайно
fetchDataRef.current?.();
// Наступні запити через інтервал
const intervalId = setInterval(() => {
fetchDataRef.current?.();
}, intervalMs);
return () => {
clearInterval(intervalId);
};
}, [url, intervalMs]);
return { data, isLoading, error };
}
// Використання
interface ServerStatus {
status: "online" | "offline";
uptime: number;
lastCheck: string;
}
function ServerMonitor() {
const { data, isLoading, error } = usePolling<ServerStatus>(
"https://api.example.com/status",
5000 // Опитування кожні 5 секунд
);
if (error) {
return <div className="error">Помилка: {error}</div>;
}
return (
<div className="monitor">
{isLoading && !data && <p>Ініціалізація...</p>}
{data && (
<>
<h2>Статус сервера: {data.status === "online" ? "🟢 Онлайн" : "🔴 Офлайн"}</h2>
<p>Uptime: {data.uptime} секунд</p>
<p>Остання перевірка: {data.lastCheck}</p>
</>
)}
{isLoading && <span className="refresh-indicator">🔄 Оновлення...</span>}
</div>
);
}
Покрокове пояснення:
fetchDataRef.current = async () => { ... }— зберігаємо функцію завантаження даних у рефі. Це дозволяєsetIntervalзавжди викликати актуальну версію функції, яка має свіжі значенняurlіз замикання.fetchDataRef.current?.()— перший виклик відбувається негайно при монтуванні або змініurl/intervalMs.setInterval(() => { fetchDataRef.current?.(); }, intervalMs)— створюємо таймер, що викликає функцію кожніintervalMsмілісекунд.clearInterval(intervalId)— очищаємо таймер при розмонтуванні або зміні залежностей.
Результат: Компонент автоматично опитує API кожні 5 секунд, показуючи актуальний статус сервера без ручного оновлення сторінки.
Архітектурні патерни організації кастомних хуків
У великих проєктах кількість кастомних хуків може зрости до десятків або сотень. Важливо організувати їх структуровано.
Рекомендована структура папок
src/
├── hooks/
│ ├── api/
│ │ ├── useFetch.ts
│ │ ├── usePolling.ts
│ │ └── useMutation.ts
│ ├── browser/
│ │ ├── useOnlineStatus.ts
│ │ ├── useWindowSize.ts
│ │ └── useMediaQuery.ts
│ ├── form/
│ │ ├── useFormValidation.ts
│ │ ├── useAutoSave.ts
│ │ └── useDebounce.ts
│ ├── storage/
│ │ ├── useLocalStorage.ts
│ │ └── useSessionStorage.ts
│ └── index.ts // Barrel export
├── components/
└── ...
Barrel export (src/hooks/index.ts):
// API хуки
export { useFetch } from "./api/useFetch";
export { usePolling } from "./api/usePolling";
export { useMutation } from "./api/useMutation";
// Browser хуки
export { useOnlineStatus } from "./browser/useOnlineStatus";
export { useWindowSize } from "./browser/useWindowSize";
export { useMediaQuery } from "./browser/useMediaQuery";
// Form хуки
export { useFormValidation } from "./form/useFormValidation";
export { useAutoSave } from "./form/useAutoSave";
export { useDebounce } from "./form/useDebounce";
// Storage хуки
export { useLocalStorage } from "./storage/useLocalStorage";
export { useSessionStorage } from "./storage/useSessionStorage";
Використання:
import { useFetch, useOnlineStatus, useLocalStorage } from "@/hooks";
Тестування кастомних хуків
Кастомні хуки можна тестувати ізольовано за допомогою @testing-library/react-hooks (для React ≤ 18) або @testing-library/react (для React 19+).
Приклад тесту для useDebounce
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";
describe("useDebounce", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("повертає початкове значення негайно", () => {
const { result } = renderHook(() => useDebounce("initial", 500));
expect(result.current).toBe("initial");
});
it("оновлює значення після затримки", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "first", delay: 500 } }
);
expect(result.current).toBe("first");
// Змінюємо value
rerender({ value: "second", delay: 500 });
// Значення ще не оновилося
expect(result.current).toBe("first");
// Прокручуємо час на 500мс
act(() => {
jest.advanceTimersByTime(500);
});
// Тепер значення оновилося
expect(result.current).toBe("second");
});
it("скидає таймер при повторній зміні", () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: "a" } }
);
rerender({ value: "b" });
act(() => { jest.advanceTimersByTime(300); });
rerender({ value: "c" });
act(() => { jest.advanceTimersByTime(300); });
// Значення досі "a", бо таймер постійно скидався
expect(result.current).toBe("a");
// Тепер чекаємо повні 500мс
act(() => { jest.advanceTimersByTime(500); });
// Значення оновилося до останнього
expect(result.current).toBe("c");
});
});
Інтерактивна демонстрація: композиція хуків
Збирання React застосунку…
Просунуті патерни: хуки з параметрами конфігурації
Коли хук стає складнішим, передача безлічі окремих параметрів стає незручною. У таких випадках використовують об'єкт конфігурації.
Приклад: useFetchWithConfig
interface FetchConfig {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: any;
enabled?: boolean; // Чи автоматично виконувати запит
refetchInterval?: number; // Автоматичне повторне завантаження
onSuccess?: (data: any) => void;
onError?: (error: Error) => void;
}
interface UseFetchWithConfigResult<T> {
data: T | null;
isLoading: boolean;
error: string | null;
refetch: () => void;
}
export function useFetchWithConfig<T>(
url: string,
config: FetchConfig = {}
): UseFetchWithConfigResult<T> {
const {
method = 'GET',
headers = {},
body,
enabled = true,
refetchInterval,
onSuccess,
onError,
} = config;
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [refetchTrigger, setRefetchTrigger] = useState<number>(0);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
async function fetchData(): Promise<void> {
try {
setIsLoading(true);
setError(null);
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
signal: controller.signal,
};
if (body && method !== 'GET') {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: T = await response.json();
setData(result);
if (onSuccess) {
onSuccess(result);
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
if (onError) {
onError(err);
}
}
} finally {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
}
fetchData();
// Автоматичне повторне завантаження
let intervalId: NodeJS.Timeout | undefined;
if (refetchInterval) {
intervalId = setInterval(() => {
fetchData();
}, refetchInterval);
}
return () => {
controller.abort();
if (intervalId) {
clearInterval(intervalId);
}
};
}, [url, method, enabled, refetchTrigger, refetchInterval]);
const refetch = () => {
setRefetchTrigger((prev) => prev + 1);
};
return { data, isLoading, error, refetch };
}
Використання з конфігурацією
interface Product {
id: number;
name: string;
price: number;
}
function ProductList() {
const [enabled, setEnabled] = useState(true);
const { data, isLoading, error, refetch } = useFetchWithConfig<Product[]>(
'https://api.example.com/products',
{
enabled,
refetchInterval: 30000, // Автооновлення кожні 30 секунд
headers: {
Authorization: 'Bearer token123',
},
onSuccess: (products) => {
console.log(`Завантажено ${products.length} товарів`);
},
onError: (err) => {
console.error('Помилка завантаження:', err);
},
}
);
return (
<div>
<div className="controls">
<button onClick={() => setEnabled(!enabled)}>
{enabled ? 'Вимкнути' : 'Увімкнути'} автозавантаження
</button>
<button onClick={refetch} disabled={!enabled}>
🔄 Оновити вручну
</button>
</div>
{isLoading && <p>Завантаження...</p>}
{error && <p className="error">{error}</p>}
{data && (
<ul>
{data.map((product) => (
<li key={product.id}>
{product.name} — {product.price} грн
</li>
))}
</ul>
)}
</div>
);
}
- Читабельність: Явні назви параметрів у виклику хука.
- Розширюваність: Легко додати нові опції без зміни сигнатури функції.
- Опціональність: Більшість параметрів мають значення за замовчуванням.
- TypeScript-підказки: IDE показує всі доступні опції при автодоповненні.
Хук для роботи з формами: useForm
Створимо універсальний хук для управління станом форми із валідацією.
Реалізація useForm
interface ValidationRule<T> {
validate: (value: T) => boolean;
message: string;
}
interface FieldConfig<T> {
initialValue: T;
rules?: ValidationRule<T>[];
}
interface UseFormResult<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
handleChange: (field: keyof T) => (value: any) => void;
handleBlur: (field: keyof T) => () => void;
handleSubmit: (onSubmit: (values: T) => void) => (e: React.FormEvent) => void;
reset: () => void;
isValid: boolean;
}
export function useForm<T extends Record<string, any>>(
config: Record<keyof T, FieldConfig<T[keyof T]>>
): UseFormResult<T> {
const initialValues = Object.keys(config).reduce((acc, key) => {
acc[key as keyof T] = config[key as keyof T].initialValue;
return acc;
}, {} as T);
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const validateField = (field: keyof T, value: any): string | null => {
const rules = config[field].rules;
if (!rules) return null;
for (const rule of rules) {
if (!rule.validate(value)) {
return rule.message;
}
}
return null;
};
const handleChange = (field: keyof T) => (value: any) => {
setValues((prev) => ({ ...prev, [field]: value }));
const error = validateField(field, value);
setErrors((prev) => ({
...prev,
[field]: error || undefined,
}));
};
const handleBlur = (field: keyof T) => () => {
setTouched((prev) => ({ ...prev, [field]: true }));
};
const handleSubmit = (onSubmit: (values: T) => void) => (e: React.FormEvent) => {
e.preventDefault();
// Позначаємо всі поля як торкнуті
const allTouched = Object.keys(config).reduce((acc, key) => {
acc[key as keyof T] = true;
return acc;
}, {} as Partial<Record<keyof T, boolean>>);
setTouched(allTouched);
// Валідуємо всі поля
const allErrors: Partial<Record<keyof T, string>> = {};
let hasErrors = false;
for (const field of Object.keys(config) as Array<keyof T>) {
const error = validateField(field, values[field]);
if (error) {
allErrors[field] = error;
hasErrors = true;
}
}
setErrors(allErrors);
if (!hasErrors) {
onSubmit(values);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
const isValid = Object.keys(errors).length === 0;
return {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
reset,
isValid,
};
}
Використання useForm
interface LoginFormValues {
email: string;
password: string;
}
function LoginForm() {
const { values, errors, touched, handleChange, handleBlur, handleSubmit, reset } =
useForm<LoginFormValues>({
email: {
initialValue: '',
rules: [
{
validate: (value) => value.length > 0,
message: "Email є обов'язковим",
},
{
validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Некоректний формат email',
},
],
},
password: {
initialValue: '',
rules: [
{
validate: (value) => value.length >= 8,
message: 'Пароль має містити мінімум 8 символів',
},
],
},
});
function onSubmit(data: LoginFormValues) {
console.log('Відправка форми:', data);
// Виклик API для входу
reset();
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="field">
<label>Email:</label>
<input
type="email"
value={values.email}
onChange={(e) => handleChange('email')(e.target.value)}
onBlur={handleBlur('email')}
/>
{touched.email && errors.email && (
<span className="error">{errors.email}</span>
)}
</div>
<div className="field">
<label>Пароль:</label>
<input
type="password"
value={values.password}
onChange={(e) => handleChange('password')(e.target.value)}
onBlur={handleBlur('password')}
/>
{touched.password && errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<button type="submit">Увійти</button>
</form>
);
}
Найпоширеніші помилки при створенні кастомних хуків
Помилка 1: Порушення правил хуків
// ❌ ПОГАНО: хук викликається умовно
function useBadHook(shouldUse: boolean) {
if (shouldUse) {
const [state, setState] = useState(0); // Порушення правил!
return state;
}
return null;
}
// ✅ ДОБРЕ: хук завжди викликається
function useGoodHook(shouldUse: boolean) {
const [state, setState] = useState(0);
if (!shouldUse) {
return null;
}
return state;
}
Помилка 2: Забування мемоізації колбеків
// ❌ ПОГАНО: новий колбек при кожному рендері
function useBadCallback() {
const [count, setCount] = useState(0);
// Ця функція створюється заново при кожному рендері
const increment = () => setCount(count + 1);
return { count, increment };
}
// ✅ ДОБРЕ: мемоізований колбек
function useGoodCallback() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount((prev) => prev + 1);
}, []); // Стабільне посилання
return { count, increment };
}
Помилка 3: Повернення нестабільних об'єктів
// ❌ ПОГАНО: новий об'єкт при кожному рендері
function useBadReturn() {
const [count, setCount] = useState(0);
return {
count,
increment: () => setCount(count + 1),
}; // Новий об'єкт щоразу — порушує референсну рівність
}
// ✅ ДОБРЕ: мемоізований об'єкт
function useGoodReturn() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return useMemo(() => ({
count,
increment,
}), [count, increment]);
}
- Хук використовується у залежностях
useEffectіншого компонента. - Об'єкт передається як пропс у компонент, обгорнутий у
React.memo. - Виклик функції є дорогою операцією (складні обчислення).
useMemo/useCallback лише за наявності реальних проблем продуктивності.Резюме: переваги кастомних хуків
🎯 Повторне використання
🔒 Інкапсуляція
🧪 Тестованість
🧩 Композиційність
useSearchWithDebounce комбінує useDebounce + useFetch без дублювання логіки.📖 Читабельність
const isOnline = useOnlineStatus() замість 20 рядків коду підписок.⚡ Продуктивність
Підсумок лекції
✅ Ви навчилися
- Створювати кастомні хуки за правилами React (префікс
use, виклик на верхньому рівні). - Розуміти ізоляцію стану: кожен виклик хука має власний незалежний стан.
- Будувати типізовані хуки для типових сценаріїв:
useFetch,useLocalStorage,useDebounce,useOnlineStatus. - Застосовувати композицію хуків для побудови складних абстракцій із простих.
- Організовувати хуки у великих проєктах через модульну структуру папок.
- Тестувати кастомні хуки ізольовано без рендерингу компонентів.
🔮 Наступні кроки
Видалення зайвих залежностей ефектів та оптимізація синхронізації
Чотири стратегії усунення непотрібних залежностей: винесення статичного коду, переміщення всередину ефекту, функціональні оновлення стану та розділення ефектів. Практичні рецепти для правильної роботи з exhaustive-deps.
React Router v7: Маршрутизація в SPA
Повний курс клієнтської маршрутизації через React Router v7 у режимі бібліотеки: від базової конфігурації до складних вкладених маршрутів, динамічних параметрів, програмної навігації та обробки помилок.