React Custom Hooks: Patterns for Reusable Logic
Master React custom hooks with practical patterns for data fetching, form handling, debouncing, and more. Learn when and how to extract reusable logic.
What you'll learn
- ✓When to extract logic into custom hooks
- ✓Common custom hook patterns with real examples
- ✓Composing hooks together for complex behavior
- ✓Testing strategies for custom hooks
Prerequisites
- •React hooks (useState, useEffect, useRef)
- •Basic TypeScript knowledge
Custom hooks are functions that start with use and call other hooks inside them. They let you extract component logic into reusable functions without changing your component hierarchy. This guide covers practical patterns you will actually use in production apps.
When to Create a Custom Hook
Extract a custom hook when you find yourself copying the same useState + useEffect combination across multiple components. The rule is simple: if two components share the same stateful logic, pull it into a hook.
Here is a common example. Many components need to track window dimensions:
// Before: duplicated in every component that needs it
function ComponentA() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return <div>Width: {width}</div>;
}
Extract it once, use it everywhere:
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return width;
}
function ComponentA() {
const width = useWindowWidth();
return <div>Width: {width}</div>;
}
Pattern 1: Data Fetching Hook
The most common custom hook pattern wraps an async operation with loading and error states:
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
loading: boolean;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, error, loading, refetch: fetchData };
}
Usage is clean and consistent:
function UserList() {
const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Pattern 2: Debounced Value Hook
Debouncing is needed whenever you want to delay processing until the user stops an action, like typing in a search field:
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;
}
Combine it with the fetch hook for a search input:
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const { data: results, loading } = useFetch<SearchResult[]>(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading ? <Spinner /> : <ResultsList results={results ?? []} />}
</div>
);
}
Pattern 3: Local Storage Hook
Persist state across page reloads with a hook that syncs to localStorage:
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const nextValue = value instanceof Function ? value(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(nextValue));
} catch (err) {
console.warn(`Failed to save to localStorage key "${key}"`, err);
}
return nextValue;
});
},
[key]
);
const removeValue = useCallback(() => {
setStoredValue(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [storedValue, setValue, removeValue] as const;
}
Usage mirrors useState with persistence:
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="range"
min={12}
max={24}
value={fontSize}
onChange={e => setFontSize(Number(e.target.value))}
/>
</div>
);
}
Pattern 4: Toggle and Boolean Hooks
Simple hooks for common boolean state operations:
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse } as const;
}
This eliminates inline arrow functions scattered through your JSX:
function Modal() {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle();
return (
<>
<button onClick={open}>Open Modal</button>
{isOpen && (
<Dialog onClose={close}>
<p>Modal content here</p>
</Dialog>
)}
</>
);
}
Pattern 5: Previous Value Hook
Track what a value was on the previous render:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
Useful for comparing current and previous values to trigger side effects:
function PriceDisplay({ price }: { price: number }) {
const previousPrice = usePrevious(price);
const direction = previousPrice !== undefined
? price > previousPrice ? 'up' : price < previousPrice ? 'down' : 'same'
: 'same';
return (
<span className={`price ${direction}`}>
${price.toFixed(2)}
{direction === 'up' && ' ↑'}
{direction === 'down' && ' ↓'}
</span>
);
}
Pattern 6: Click Outside Hook
Detect clicks outside a referenced element, essential for dropdowns and popovers:
function useClickOutside(
ref: RefObject<HTMLElement | null>,
handler: () => void
) {
useEffect(() => {
function handleClick(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
}
Using it with a dropdown:
function Dropdown({ items, onSelect }: DropdownProps) {
const { value: isOpen, toggle, setFalse: close } = useToggle();
const dropdownRef = useRef<HTMLDivElement>(null);
useClickOutside(dropdownRef, close);
return (
<div ref={dropdownRef} className="dropdown">
<button onClick={toggle}>Select an option</button>
{isOpen && (
<ul className="dropdown-menu">
{items.map(item => (
<li key={item.id} onClick={() => { onSelect(item); close(); }}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
Pattern 7: Media Query Hook
React to CSS media query changes:
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
Build responsive behavior in JavaScript:
function Navigation() {
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileNav /> : <DesktopNav />;
}
Composing Hooks Together
The real power of custom hooks is composition. Combine smaller hooks into more complex behavior:
function useSearchWithHistory(endpoint: string) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const [history, setHistory] = useLocalStorage<string[]>('search-history', []);
const { data, loading, error } = useFetch<SearchResult[]>(
debouncedQuery ? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}` : ''
);
const search = useCallback((term: string) => {
setQuery(term);
if (term && !history.includes(term)) {
setHistory(prev => [term, ...prev].slice(0, 10));
}
}, [history, setHistory]);
const clearHistory = useCallback(() => {
setHistory([]);
}, [setHistory]);
return {
query,
setQuery: search,
results: data ?? [],
loading,
error,
history,
clearHistory,
};
}
This hook combines debouncing, data fetching, and local storage persistence into a single reusable interface.
Rules for Writing Good Custom Hooks
Name your hooks descriptively. useAuth is better than useData. The name should tell you what the hook does without reading its code.
Return only what consumers need. If your hook tracks five internal states but consumers only need two values, return those two. Keep the API surface small.
Keep hooks focused on one responsibility. A hook that handles both authentication and analytics should be split into useAuth and useAnalytics.
Accept configuration as parameters. Instead of hardcoding values inside the hook, let consumers pass them in:
// Bad: hardcoded delay
function useDebounce<T>(value: T) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), 500);
return () => clearTimeout(timer);
}, [value]);
return debouncedValue;
}
// Good: configurable delay
function useDebounce<T>(value: T, delay: number) { /* ... */ }
Wrapping Up
Custom hooks are one of the best tools React gives you for organizing code. Start by identifying duplicated stateful logic across your components, then extract it into hooks. Build small, focused hooks and compose them for complex behavior. The patterns in this guide cover the most common use cases: data fetching, debouncing, local storage, toggles, click outside detection, and media queries. Once you get comfortable with these, you will naturally start seeing opportunities to create hooks specific to your application’s domain.
Related articles
- React React Higher-Order Components vs Hooks: Migration Guide
Compare Higher-Order Components and hooks in React with side-by-side examples, migration strategies, and guidance on when each pattern still makes sense.
- React React Render Props vs Hooks
Compare render props and hooks as two ways to share reusable logic in React, with examples, mental models, and guidance on which pattern fits modern codebases.
- React React Compound Components: Flexible APIs Without Prop Drilling
Build flexible, composable React components using the compound components pattern with Context, implicit state sharing, and controlled inversion.
- React Building State Machines with useReducer in React
Implement finite state machines using only React's useReducer hook: explicit states, guarded transitions, and elimination of impossible UI states without libraries.