Кастомные хуки
Что такое кастомный хук?
Заголовок раздела «Что такое кастомный хук?»Кастомный хук — функция с именем, начинающимся с use, которая может вызывать другие хуки. Позволяет вынести и переиспользовать логику компонентов.
useFetch
Заголовок раздела «useFetch»type FetchState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error };
interface UseFetchOptions { enabled?: boolean; // false — не запускать автоматически}
function useFetch<T>(url: string | null, options: UseFetchOptions = {}) { const { enabled = true } = options; const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
useEffect(() => { if (!url || !enabled) return;
let ignore = false; const controller = new AbortController();
setState({ status: 'loading' });
fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); return res.json() as Promise<T>; }) .then(data => { if (!ignore) setState({ status: 'success', data }); }) .catch(error => { if (!ignore && error.name !== 'AbortError') { setState({ status: 'error', error: error instanceof Error ? error : new Error(String(error)) }); } });
return () => { ignore = true; controller.abort(); }; }, [url, enabled]);
return state;}
// Использованиеfunction UserCard({ userId }: { userId: number }) { const state = useFetch<User>(`/api/users/${userId}`);
if (state.status === 'loading') return <Spinner />; if (state.status === 'error') return <p>Ошибка: {state.error.message}</p>; if (state.status !== 'success') return null;
return <div>{state.data.name}</div>;}
// С lazy loading (enabled = false)function SearchResults() { const [query, setQuery] = useState(''); const state = useFetch<Product[]>( query ? `/api/products?q=${encodeURIComponent(query)}` : null ); // ...}useLocalStorage
Заголовок раздела «useLocalStorage»function useLocalStorage<T>(key: string, defaultValue: T) { const [value, setValue] = useState<T>(() => { try { const stored = localStorage.getItem(key); return stored !== null ? (JSON.parse(stored) as T) : defaultValue; } catch { return defaultValue; } });
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => { setValue(prev => { const next = typeof newValue === 'function' ? (newValue as (prev: T) => T)(prev) : newValue; try { localStorage.setItem(key, JSON.stringify(next)); } catch (e) { console.warn('localStorage.setItem failed:', e); } return next; }); }, [key]);
const removeValue = useCallback(() => { setValue(defaultValue); localStorage.removeItem(key); }, [key, defaultValue]);
// Синхронизация между вкладками useEffect(() => { function handleStorage(e: StorageEvent) { if (e.key !== key) return; try { setValue(e.newValue !== null ? JSON.parse(e.newValue) : defaultValue); } catch { setValue(defaultValue); } }
window.addEventListener('storage', handleStorage); return () => window.removeEventListener('storage', handleStorage); }, [key, defaultValue]);
return [value, setStoredValue, removeValue] as const;}
// Использованиеfunction Settings() { const [theme, setTheme, removeTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return ( <> <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}> Тема: {theme} </button> <button onClick={removeTheme}>Сбросить</button> </> );}useDebounce
Заголовок раздела «useDebounce»function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]);
return debouncedValue;}
// Вариант: хук возвращает debounced функциюfunction useDebouncedCallback<T extends (...args: never[]) => unknown>( callback: T, delay: number): T { const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); const callbackRef = useRef(callback); callbackRef.current = callback;
return useCallback((...args: Parameters<T>) => { clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout( () => callbackRef.current(...args), delay ); }, [delay]) as T;}
// Использованиеfunction SearchInput() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300);
const state = useFetch<Product[]>( debouncedQuery ? `/api/search?q=${debouncedQuery}` : null );
return ( <> <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Поиск..." /> {state.status === 'success' && ( <ul>{state.data.map(p => <li key={p.id}>{p.name}</li>)}</ul> )} </> );}useEventListener
Заголовок раздела «useEventListener»function useEventListener< TTarget extends EventTarget, TEventName extends string>( target: TTarget | null | (() => TTarget | null), eventName: TEventName, handler: (event: Event) => void, options?: AddEventListenerOptions) { const handlerRef = useRef(handler); handlerRef.current = handler;
useEffect(() => { const el = typeof target === 'function' ? target() : target; if (!el) return;
const listener = (e: Event) => handlerRef.current(e); el.addEventListener(eventName, listener, options); return () => el.removeEventListener(eventName, listener, options); }, [target, eventName, options?.capture, options?.passive, options?.once]);}
// Типизированные версии для window / documentfunction useWindowEvent<K extends keyof WindowEventMap>( eventName: K, handler: (event: WindowEventMap[K]) => void, options?: AddEventListenerOptions) { useEventListener(window, eventName, handler as (e: Event) => void, options);}
// Использованиеfunction KeyboardShortcuts() { useWindowEvent('keydown', e => { if (e.ctrlKey && e.key === 'k') { e.preventDefault(); openSearch(); } });
return null;}
function ClickOutside({ onClose }: { onClose: () => void }) { const ref = useRef<HTMLDivElement>(null);
useEventListener(document, 'mousedown', e => { if (ref.current && !ref.current.contains(e.target as Node)) { onClose(); } });
return <div ref={ref}>Меню</div>;}useInterval / useTimeout
Заголовок раздела «useInterval / useTimeout»function useInterval(callback: () => void, delay: number | null) { const callbackRef = useRef(callback); callbackRef.current = callback;
useEffect(() => { if (delay === null) return; // null — пауза
const id = setInterval(() => callbackRef.current(), delay); return () => clearInterval(id); }, [delay]);}
function useTimeout(callback: () => void, delay: number | null) { const callbackRef = useRef(callback); callbackRef.current = callback;
useEffect(() => { if (delay === null) return;
const id = setTimeout(() => callbackRef.current(), delay); return () => clearTimeout(id); }, [delay]);}
// Использованиеfunction Notification({ duration = 3000, onClose }: { duration?: number; onClose: () => void }) { useTimeout(onClose, duration); return <div className="notification">Уведомление</div>;}
function LiveClock() { const [time, setTime] = useState(new Date()); useInterval(() => setTime(new Date()), 1000); return <time>{time.toLocaleTimeString()}</time>;}Правила создания кастомных хуков
Заголовок раздела «Правила создания кастомных хуков»// ✅ Хорошо: хук инкапсулирует логикуfunction useToggle(initial = false) { const [on, setOn] = useState(initial); const toggle = useCallback(() => setOn(v => !v), []); const setTrue = useCallback(() => setOn(true), []); const setFalse = useCallback(() => setOn(false), []); return { on, toggle, setTrue, setFalse };}
// ✅ Хорошо: хук не знает о компоненте, только о логикеfunction useCounter(initial = 0, { min = -Infinity, max = Infinity } = {}) { const [count, setCount] = useState(initial); const increment = useCallback(() => setCount(c => Math.min(c + 1, max)), [max]); const decrement = useCallback(() => setCount(c => Math.max(c - 1, min)), [min]); const reset = useCallback(() => setCount(initial), [initial]); return { count, increment, decrement, reset };}
// ❌ Плохо: возвращает JSX — это уже компонент, не хукfunction useUserCard(userId: number) { const user = useFetch<User>(`/api/users/${userId}`); return <div>{/* ... */}</div>; // ❌ хуки не должны возвращать JSX}