Custom hooks are one of React's most underused tools. They let you extract stateful logic from components into reusable functions — without adding components to your tree, without changing component hierarchy, and without any of the complexity of higher-order components or render props.
Here's the pattern, with practical examples from the kind of stateful logic that shows up repeatedly in real applications, including the generation state patterns used at pixova.io/blog/free-ai-architecture-generator.
The Core Idea
Any logic that uses React hooks (useState, useEffect, useRef, useCallback) can be extracted into a custom hook. The hook is just a function whose name starts with use. It can call any hooks internally and return whatever the calling component needs.
// Before — component with embedded stateful logic
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
setIsLoading(true);
const data = await searchApi(query);
setResults(data);
setIsLoading(false);
}, 300);
return () => clearTimeout(timeoutId);
}, [query]);
return (/* ...render... */);
}
That search logic — debounced API call, loading state, results — is now locked inside this component. You can't reuse it in a different search component without copying the logic.
// After — extracted into a custom hook
function useSearch(searchFn: (query: string) => Promise<any[]>, debounceMs = 300) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
setIsLoading(true);
const data = await searchFn(query);
setResults(data);
setIsLoading(false);
}, debounceMs);
return () => clearTimeout(timeoutId);
}, [query, searchFn, debounceMs]);
return { query, setQuery, results, isLoading };
}
// Reusable anywhere
function SearchBox() {
const { query, setQuery, results, isLoading } = useSearch(searchApi);
return (/* ...render... */);
}
function UserSearch() {
const { query, setQuery, results, isLoading } = useSearch(searchUsers, 500);
return (/* ...render... */);
}
Practical Custom Hook Examples
useLocalStorage — Persisted State
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(`Error saving to localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue] as const;
}
// Usage — exactly like useState but persisted
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [preferences, setPreferences] = useLocalStorage('user-prefs', defaultPrefs);
useAsync — Managing Async Operations
type AsyncState<T> = {
status: 'idle' | 'pending' | 'success' | 'error';
data: T | null;
error: Error | null;
};
function useAsync<T>() {
const [state, setState] = useState<AsyncState<T>>({
status: 'idle',
data: null,
error: null,
});
const execute = useCallback(async (promise: Promise<T>) => {
setState({ status: 'pending', data: null, error: null });
try {
const data = await promise;
setState({ status: 'success', data, error: null });
return data;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
setState({ status: 'error', data: null, error: err });
throw err;
}
}, []);
const reset = useCallback(() => {
setState({ status: 'idle', data: null, error: null });
}, []);
return {
...state,
execute,
reset,
isIdle: state.status === 'idle',
isPending: state.status === 'pending',
isSuccess: state.status === 'success',
isError: state.status === 'error',
};
}
// Usage
function GenerateButton() {
const { execute, isPending, data, error } = useAsync<GeneratedImage>();
const handleGenerate = async () => {
await execute(generateImage(prompt));
};
return (
<div>
<button onClick={handleGenerate} disabled={isPending}>
{isPending ? 'Generating...' : 'Generate'}
</button>
{data && <img src={data.url} alt="Generated" />}
{error && <p>Error: {error.message}</p>}
</div>
);
}
useMediaQuery — Responsive Logic in Components
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
mediaQuery.addEventListener('change', handler);
setMatches(mediaQuery.matches);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage
function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
return (
<div>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</div>
);
}
useClickOutside — Dismissing Popups
function useClickOutside<T extends HTMLElement>(
callback: () => void
): React.RefObject<T> {
const ref = useRef<T>(null);
useEffect(() => {
const handler = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [callback]);
return ref;
}
// Usage
function Dropdown({ onClose }) {
const dropdownRef = useClickOutside<HTMLDivElement>(onClose);
return (
<div ref={dropdownRef} className="dropdown">
{/* dropdown content */}
</div>
);
}
Rules for Custom Hooks
Must start with use. React's linting rules enforce this. It's not just convention — it tells React to apply its hook rules to the function.
Can call other hooks. Custom hooks can call useState, useEffect, other custom hooks — anything. The rules of hooks (call at top level, not in conditions) apply inside custom hooks too.
Don't share state. Each component that calls a custom hook gets its own isolated state. If you need shared state between components, use context, Zustand, or another state management solution.
Return what the caller needs. There's no required return shape — objects, arrays, single values, or nothing. Design the return value around what calling components actually need.
When to Extract a Custom Hook
A good signal: you've copied the same useState + useEffect combination to two different components. Extract it.
Another signal: a component has more than two or three separate concerns managed with hooks. Separate them into hooks with clear single responsibilities.
A final signal: you want to test stateful logic without mounting a component. Custom hooks are testable with React Testing Library's renderHook without any component mounting.
Summary
Custom hooks extract stateful logic into reusable functions. They're the clean solution to the "I need to reuse this logic" problem — no extra components, no prop drilling, no wrapper complexity.
The pattern is simple: identify logic that uses React hooks, move it to a use-prefixed function, return what components need. Start with your most-duplicated stateful patterns and extract from there.
Testing Custom Hooks
renderHook from React Testing Library tests hooks without component wrappers:
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
test('useLocalStorage initializes with stored value', () => {
localStorage.setItem('test-key', JSON.stringify('stored-value'));
const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
expect(result.current[0]).toBe('stored-value');
});
test('useLocalStorage updates localStorage on setValue', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('updated');
});
Testing hooks in isolation makes it easy to cover all the edge cases — error handling, initial state, state transitions — without rendering full component trees.
Top comments (0)