Custom Hook Extraction
Components that handle data fetching, form validation and UI state all at once are hard to test, hard to maintain and hard to reuse. Custom Hook Extraction is the fundamental React pattern that separates logic from presentation, making both better at the same time.
Table of Contents
- 1. Why Custom Hook Extraction Is a Foundational Pattern
- 2. Anatomy of a Custom Hook
- 3. Pattern: Data-Fetching Hooks
- 4. Pattern: Form Logic Hooks
- 5. Pattern: Event and Browser API Hooks
- 6. Performance: Using useCallback and useMemo Correctly
- 7. Fully Typing Custom Hooks with TypeScript
- 8. Testing Strategy: renderHook and Mocking
- 9. When to Use Hooks, and When Other Patterns Fit Better
- 10. Summary
- 11. FAQ
1. Why Custom Hook Extraction Is a Foundational Pattern
Custom Hook Extraction is the most basic of all React design patterns because it applies the single responsibility principle directly to React components. A component that simultaneously handles data fetching, form validation, error handling and UI state violates SRP in the most obvious way possible. The code becomes unreadable, logic cannot be tested without starting the entire render, and the same logic gets copied between similar components instead of shared.
The way out is extracting the logic into custom hooks: functions that start with use, are allowed to use other hooks, and return a value. Custom hooks are regular TypeScript functions that happen to use hooks, which makes them testable in isolation with renderHook, reusable across other components and projects, and understandable simply by reading the signature. The component itself becomes a pure view: it calls hooks, passes data to JSX and handles user events.
A realistic measure of how successful the extraction was: after restructuring, the component should no longer contain any useEffect calls (those belong in specific hooks), no complex useMemo or useCallback blocks (those belong in the hook that owns the computation), and ideally stay under thirty lines of JSX. That is not a dogmatic goal, but it is a good indicator that the logic-presentation separation has succeeded.
2. Anatomy of a Custom Hook
A custom hook is a JavaScript function whose name starts with use and which is allowed to call other React hooks. The naming convention is not optional. React relies on it to statically check the Rules of Hooks. A custom hook may hold state (useState), run side effects (useEffect), call other custom hooks and return values. It may not return JSX, may not manipulate the DOM outside of useEffect, and may not run direct side effects outside of hooks.
The return shape of a custom hook is an important design decision: a tuple [value, setter] like useState is ideal when the hook encapsulates a single main concept. An object { data, isLoading, error, refetch } is better when the hook returns several independent values and actions. The rule of thumb: if the consumer wants to rename the returned values, an object is better (destructuring with renaming: const { data: products } = useProducts()). If the order is self-evident, as with [value, setValue], a tuple is more elegant.
// use-local-storage.ts: Generic hook with tuple return and full TypeScript inference
import { useState, useCallback, useEffect } from 'react';
type SetValue<T> = (value: T | ((prev: T) => T)) => void;
export function useLocalStorage<T>(key: string, initialValue: T): [T, SetValue<T>, () => void] {
// Initialize from localStorage or fall back to initialValue
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue; // SSR guard
try {
const item = localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
// Sync to localStorage on every change
const setValue: SetValue<T> = useCallback((value) => {
setStoredValue((prev) => {
const next = typeof value === 'function' ? (value as (p: T) => T)(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
console.warn(`useLocalStorage: could not save key "${key}"`);
}
return next;
});
}, [key]);
const removeValue = useCallback(() => {
localStorage.removeItem(key);
setStoredValue(initialValue);
}, [key, initialValue]);
// Cross-tab sync
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
setStoredValue(JSON.parse(e.newValue) as T);
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [key]);
return [storedValue, setValue, removeValue];
}
// Usage: clean component, no localStorage logic visible
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
3. Pattern: Data-Fetching Hooks
Data-fetching hooks are the most common category of custom hooks. They encapsulate the full lifecycle of an API call: initial loading, loading state, data, error handling, cancellation on unmount and optional refetching. Without extraction this code often ends up in a single useEffect block inside the component, complex, hard to test and not reusable.
A well-designed data-fetching hook returns at least data, isLoading, error and a refetch function. It aborts in-flight requests when the hook unmounts (via AbortController). It memoizes the fetch function with useCallback so that dependency arrays in useEffect stay stable. In practice, simple data fetching today is often handled with React Query (@tanstack/react-query), which makes sense, but understanding the underlying pattern is a prerequisite for configuring React Query correctly.
// use-fetch.ts: Generic data-fetching hook with abort and error handling
import { useState, useEffect, useCallback, useRef } from 'react';
interface FetchState<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
export function useFetch<T>(url: string, options?: RequestInit): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [refetchCounter, setRefetchCounter] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const refetch = useCallback(() => setRefetchCounter(c => c + 1), []);
useEffect(() => {
// Abort previous request if URL changes or component unmounts
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
setError(null);
fetch(url, { ...options, signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<T>;
})
.then(json => {
setData(json);
setIsLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return; // Ignore cancellation
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
});
return () => controller.abort();
}, [url, refetchCounter]); // options excluded, pass stable object or stringify
return { data, isLoading, error, refetch };
}
// Clean component: no fetch logic, only presentation
function ProductList({ categoryId }: { categoryId: string }) {
const { data: products, isLoading, error, refetch } = useFetch<Product[]>(
`/api/products?category=${categoryId}`
);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage message={error.message} onRetry={refetch} />;
return <ul>{products?.map(p => <ProductCard key={p.id} product={p} />)}</ul>;
}
4. Pattern: Form Logic Hooks
Form logic is one of the most complex areas in React applications: fields have values, validation rules, touched states and error messages. Keeping all of that inside one component quickly makes it unmanageable. A form hook encapsulates this state and the related logic: value changes, validation on blur, submit handling and reset. The component itself only binds the fields and displays errors.
The key to a well-designed form hook is the stability of the returned handler functions: handleChange, handleBlur and handleSubmit should be memoized with useCallback so that fields receiving these functions as props do not re-render on every render. In practice this approach complements React Hook Form very well: for complex forms you reach for React Hook Form, and for simple forms a hand-rolled hook is enough and saves the dependency.
5. Pattern: Event and Browser API Hooks
Browser APIs like Intersection Observer, ResizeObserver, media queries, Geolocation and Clipboard are cumbersome and error-prone to use directly in React components: cleanup in useEffect gets forgotten, and multiple components duplicate the same observer code. Custom hooks solve this problem elegantly: a useIntersectionObserver hook encapsulates the entire observer lifecycle, and a useMediaQuery hook synchronizes media query results with React state.
The most important detail for browser API hooks is the cleanup function inside useEffect. Observers must be disconnected, event listeners removed and subscriptions ended when the hook unmounts. Missing cleanup is the most common source of memory leaks in React applications. A well-tested custom hook covers this scenario explicitly: the test checks that no observer or listener is still active after unmount.
// use-intersection-observer.ts: Lazy loading hook via IntersectionObserver
import { useEffect, useRef, useState, type RefObject } from 'react';
interface UseIntersectionObserverOptions {
threshold?: number | number[];
rootMargin?: string;
root?: Element | null;
once?: boolean; // stop observing after first intersection
}
export function useIntersectionObserver<T extends Element>(
options: UseIntersectionObserverOptions = {}
): [RefObject<T | null>, boolean] {
const { threshold = 0.1, rootMargin = '0px', root = null, once = false } = options;
const ref = useRef<T>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
([entry]) => {
const intersecting = entry.isIntersecting;
setIsIntersecting(intersecting);
// Disconnect after first intersection if once=true
if (intersecting && once) {
observer.disconnect();
}
},
{ threshold, rootMargin, root }
);
observer.observe(element);
// Cleanup: always disconnect to prevent memory leaks
return () => observer.disconnect();
}, [threshold, rootMargin, root, once]);
return [ref, isIntersecting];
}
// use-debounce.ts: Debounce hook for search inputs
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer); // cleanup prevents stale updates
}, [value, delay]);
return debouncedValue;
}
// Clean component combining both hooks
function LazySearchResults({ query }: { query: string }) {
const debouncedQuery = useDebounce(query, 300);
const [containerRef, isVisible] = useIntersectionObserver<HTMLDivElement>({ once: true });
const { data, isLoading } = useFetch<Result[]>(
isVisible && debouncedQuery ? `/api/search?q=${debouncedQuery}` : ''
);
return (
<div ref={containerRef}>
{isVisible && (isLoading ? <Spinner /> : <ResultList items={data ?? []} />)}
</div>
);
}
6. Performance: Using useCallback and useMemo Correctly
A common misconception about custom hooks is that useCallback and useMemo should always be used. The opposite is true: both hooks carry overhead from the memoization mechanism, which only pays off when the computation or function is considerably more expensive than the memoization itself. The rule of thumb: useMemo for expensive computations (filtering large arrays, complex transformations), useCallback for functions that are passed as props to optimized child components (React.memo) or that appear as dependencies in useEffect arrays.
In custom hooks, useCallback and useMemo are justified more often than in components, because the hook returns state and functions that cannot be controlled from the outside. A refetch function without useCallback creates a new reference on every re-render, which re-triggers every useEffect dependency array that contains that function, every single time. That is almost always a bug. Inside custom hooks, useCallback for returned handler functions is therefore the rule, not the exception.
7. Fully Typing Custom Hooks with TypeScript
TypeScript and custom hooks complement each other extremely well, because hooks in TypeScript can be fully parameterized through generics. A useFetch<T> hook infers the data type from the generic parameter and returns data: T | null, giving the consumer full autocompletion for the data type. That eliminates the need for cast operators (as Product[]) inside components.
Return types of custom hooks should be explicitly annotated when the hook returns a tuple. TypeScript infers tuples as arrays when the return type is not explicitly annotated: return [storedValue, setValue] gets inferred as (T | SetValue<T>)[], not as [T, SetValue<T>]. That leads to imprecise types during destructuring. The solution: either an explicit return type : [T, SetValue<T>], or return [storedValue, setValue] as const.
8. Testing Strategy: renderHook and Mocking
renderHook from @testing-library/react is the primary tool for testing custom hooks in isolation. It renders the hook inside a minimal React environment and exposes the result for assertions. The act function wraps state updates so React processes all effects synchronously. A data-fetching hook can be tested by mocking the global fetch function, without making real network requests.
A complete test suite for a custom hook covers the following scenarios: initial render (initial state values are correct), state transitions (actions change state as expected), cleanup (no memory leak after unmount), edge cases (empty inputs, errors, null values) and async behavior (loading state, successful request, failed request). This test coverage is barely achievable without custom hooks, because the logic is entangled with rendering.
9. When to Use Hooks, and When Other Patterns Fit Better
Custom hooks are the right tool when reusable logic with React state or effects needs to be extracted. They are not the right tool for pure calculation functions (regular functions are enough for that), for sharing state between unrelated components (Zustand or Jotai are better for that), or for splitting render logic across multiple components (Compound Components are better for that).
| Requirement | Recommended Pattern | Reasoning |
|---|---|---|
| Reuse data fetching | Custom Hook (useFetch) | State and effects belong together, test via renderHook |
| State between distant components | Zustand / Jotai | Hooks only share state within a component |
| Flexible UI composition | Compound Components | Hooks cannot return JSX |
| Pure computation without state | Regular function | No hook overhead needed, easier to test |
| Encapsulate browser API | Custom Hook | Cleanup centralized in useEffect, testable in isolation |
The strongest combination in practice is custom hooks plus Compound Components: the hook provides the logic, the Compound Components structure the rendering. A tabs hook manages the active tab and keyboard navigation, the tabs Compound Component renders the structure. The component stays clean, the hook stays testable in isolation. That is React architecture at the current state of the practice.