Перейти к содержимому

Кастомные хуки

Кастомный хук — функция с именем, начинающимся с use, которая может вызывать другие хуки. Позволяет вынести и переиспользовать логику компонентов.

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
);
// ...
}
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>
</>
);
}
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>
)}
</>
);
}
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 / document
function 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>;
}
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
}