Custom Hooks
Custom Hooks
Section titled โCustom HooksโCustom hooks are functions that start with use and can call other hooks. They let you extract stateful logic out of components so it can be reused across multiple components.
Why Custom Hooks
Section titled โWhy Custom Hooksโ- Reuse stateful logic without restructuring component hierarchy
- Separate concerns โ UI components stay focused on rendering
- Test logic independently from components
Basic Pattern
Section titled โBasic Patternโ// A hook that fetches datafunction useFetch<T>(url: string) { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null);
useEffect(() => { let cancelled = false;
fetch(url) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise<T>; }) .then(data => { if (!cancelled) setData(data); }) .catch(err => { if (!cancelled) setError(err); }) .finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; }, [url]);
return { data, loading, error };}
// Usagefunction UserProfile({ userId }: { userId: string }) { const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Spinner />; if (error) return <ErrorMessage error={error} />; return <div>{user?.name}</div>;}useLocalStorage
Section titled โuseLocalStorageโPersist state across page reloads:
function useLocalStorage<T>(key: string, initialValue: T) { const [value, setValue] = useState<T>(() => { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initialValue; } catch { return initialValue; } });
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => { setValue(prev => { const next = typeof newValue === 'function' ? (newValue as (prev: T) => T)(prev) : newValue; localStorage.setItem(key, JSON.stringify(next)); return next; }); }, [key]);
return [value, setStoredValue] as const;}
// Usageconst [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');useDebounce
Section titled โuseDebounceโDelay state updates โ useful for search inputs:
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;}
// Usagefunction SearchInput() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300);
useEffect(() => { if (debouncedQuery) fetchResults(debouncedQuery); }, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;}useMediaQuery
Section titled โuseMediaQueryโRespond to viewport changes:
function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState( () => window.matchMedia(query).matches );
useEffect(() => { const media = window.matchMedia(query); const handler = (e: MediaQueryListEvent) => setMatches(e.matches); media.addEventListener('change', handler); return () => media.removeEventListener('change', handler); }, [query]);
return matches;}
// Usageconst isMobile = useMediaQuery('(max-width: 768px)');useClickOutside
Section titled โuseClickOutsideโClose dropdowns and modals when clicking outside:
function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) { useEffect(() => { const listener = (event: MouseEvent | TouchEvent) => { if (!ref.current || ref.current.contains(event.target as Node)) return; handler(); };
document.addEventListener('mousedown', listener); document.addEventListener('touchstart', listener); return () => { document.removeEventListener('mousedown', listener); document.removeEventListener('touchstart', listener); }; }, [ref, handler]);}
// Usagefunction Dropdown() { const [open, setOpen] = useState(false); const ref = useRef<HTMLDivElement>(null); useClickOutside(ref, () => setOpen(false));
return ( <div ref={ref}> <button onClick={() => setOpen(o => !o)}>Toggle</button> {open && <div className="dropdown-menu">...</div>} </div> );}usePrevious
Section titled โusePreviousโAccess the previous value of a state or prop:
function usePrevious<T>(value: T): T | undefined { const ref = useRef<T>(); useEffect(() => { ref.current = value; }); return ref.current;}
// Usagefunction Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count);
return <div>Current: {count}, Previous: {prevCount}</div>;}Custom hooks follow the same rules as built-in hooks:
- Only call hooks at the top level (not in loops or conditions)
- Only call hooks from React functions or other hooks
- Name must start with
useโ this is what lets linters enforce the rules above