Introduction
Once you understand the basics of custom hooks, you can build increasingly powerful abstractions. This lesson covers advanced patterns: hooks that wrap async operations, hooks that manage complex state machines, and hooks that compose other hooks. These patterns are the building blocks of production-grade React applications.
Key Concepts
- Async Hook Pattern: A hook that manages loading, data, and error states for asynchronous operations.
- State Machine Hook: A hook that encapsulates a finite state machine with defined transitions.
- Composed Hooks: Building complex hooks by combining simpler hooks.
- Generic Hooks: Using TypeScript generics to create type-safe, reusable hooks.
Real World Context
Every production application needs to fetch data, handle loading states, manage errors, and often retry failed requests. Libraries like TanStack Query started as custom hooks. Understanding the async hook pattern helps you both use these libraries effectively and build custom solutions when a library is overkill.
Deep Dive
The Async Hook Pattern
tsxtype AsyncState<T> = | { status: 'idle'; data: null; error: null } | { status: 'loading'; data: null; error: null } | { status: 'success'; data: T; error: null } | { status: 'error'; data: null; error: Error }; function useAsync<T>(asyncFn: () => Promise<T>, deps: unknown[]) { const [state, setState] = useState<AsyncState<T>>({ status: 'idle', data: null, error: null, }); useEffect(() => { let cancelled = false; setState({ status: 'loading', data: null, error: null }); asyncFn() .then(data => { if (!cancelled) setState({ status: 'success', data, error: null }); }) .catch(error => { if (!cancelled) setState({ status: 'error', data: null, error }); }); return () => { cancelled = true; }; }, deps); return state; } // Usage function UserProfile({ userId }: { userId: string }) { const { status, data: user, error } = useAsync( () => fetch(`/api/users/${userId}`).then(r => r.json()), [userId] ); if (status === 'loading') return <Spinner />; if (status === 'error') return <p>Error: {error.message}</p>; if (status === 'idle' || !user) return null; return <h1>{user.name}</h1>; }
Composing Hooks
Build complex hooks from simpler ones:
tsxfunction useSearchWithDebounce(apiUrl: string) { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); const { data: results, status } = useAsync( () => debouncedQuery ? fetch(`${apiUrl}?q=${debouncedQuery}`).then(r => r.json()) : Promise.resolve([]), [debouncedQuery, apiUrl] ); return { query, setQuery, results: results || [], isSearching: status === 'loading' }; }
The Reducer Hook Pattern
For complex state with many transitions, combine useReducer with a custom hook:
tsxtype WizardState = { step: number; data: Record<string, unknown>; isComplete: boolean; }; type WizardAction = | { type: 'next'; payload: Record<string, unknown> } | { type: 'back' } | { type: 'complete' } | { type: 'reset' }; function useWizard(totalSteps: number) { const [state, dispatch] = useReducer( (state: WizardState, action: WizardAction): WizardState => { switch (action.type) { case 'next': return { ...state, step: Math.min(state.step + 1, totalSteps - 1), data: { ...state.data, ...action.payload }, }; case 'back': return { ...state, step: Math.max(state.step - 1, 0) }; case 'complete': return { ...state, isComplete: true }; case 'reset': return { step: 0, data: {}, isComplete: false }; default: return state; } }, { step: 0, data: {}, isComplete: false } ); return { step: state.step, data: state.data, isComplete: state.isComplete, isFirst: state.step === 0, isLast: state.step === totalSteps - 1, next: (data: Record<string, unknown>) => dispatch({ type: 'next', payload: data }), back: () => dispatch({ type: 'back' }), complete: () => dispatch({ type: 'complete' }), reset: () => dispatch({ type: 'reset' }), }; }
Common Pitfalls
- Unstable dependency arrays in composed hooks — When passing functions or objects as hook arguments, make sure they are stable (memoized) to avoid infinite re-render loops.
- Missing cancellation in async hooks — Always use a
cancelledflag orAbortControllerto prevent setting state on unmounted components.
Best Practices
- Type the return value — Use TypeScript discriminated unions for async state so consumers can narrow types with status checks.
- Accept configuration over hard-coding behavior — Make hooks flexible by accepting options:
useDebounce(value, delay)is more reusable thanuseDebounce300ms(value).
Summary
- The async hook pattern encapsulates loading, success, and error states in a reusable way.
- Compose complex hooks from simpler ones: debounce + async = search with debounce.
- Use TypeScript discriminated unions and generics to make hooks type-safe and flexible.
Code Examples
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { setData(json); setError(null); })
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}