Custom Hooks: Abstraction, Reuse and Design Patterns
Why the Rules of Hooks Exist: The Linked List in Fiber
Before writing custom Hooks, you need to understand why Hooks have rules at all โ specifically, why you cannot call them inside conditionals, loops, or nested functions.
The answer lives in React's Fiber architecture. Each component instance maintains a linked list of Hook nodes on its Fiber. The first useState call creates the first node, the second creates the second, and so on. On every re-render, React walks this list in order and matches each Hook call to its corresponding node by position.
Render 1:
useState(0) โ Node 1: { state: 0, queue: [] }
useState('') โ Node 2: { state: '', queue: [] }
useEffect(fn) โ Node 3: { effect: fn, deps: [] }
Render 2 (position-based matching):
useState(0) โ reads Node 1 โ returns current count
useState('') โ reads Node 2 โ returns current text
useEffect(fn) โ reads Node 3 โ compares deps
If you call a Hook conditionally, some renders skip it. The hooks that follow then read from the wrong nodes in the list โ state crosses between hooks, producing corrupted values that are nearly impossible to debug.
Custom Hooks are not a new React concept โ they are just functions that call Hooks. The rules extend automatically because calling a custom Hook is calling Hooks. The linked list constraint applies through every level of extraction.
When to Extract a Custom Hook
Signals that an extraction is warranted:
- The same Hook combination appears in multiple components โ copy-pasted logic is the clearest signal
- A component has so many Hook calls that the rendering logic is buried โ extraction improves readability
- You want to give a piece of stateful logic a name โ names communicate intent
- You need to test stateful logic in isolation โ Hooks are easier to unit test than full components
When NOT to extract: logic used by exactly one component and simple enough to read inline. Over-abstraction is as harmful as under-abstraction. A custom Hook with one useState and no reuse across components is ceremony without benefit.
useFetch: A Production-Grade Data Fetching Hook
Data fetching is the most common custom Hook use case and the most error-prone when implemented naively.
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function useFetch<T>(url: string | null): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
useEffect(() => {
if (!url) {
setState({ status: 'idle' });
return;
}
const controller = new AbortController();
setState({ status: 'loading' });
fetch(url, { signal: controller.signal })
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
})
.then(data => setState({ status: 'success', data }))
.catch(error => {
if (error.name !== 'AbortError') {
setState({ status: 'error', error });
}
});
return () => controller.abort();
}, [url]);
return state;
}
// Usage: exhaustive pattern matching on the discriminated union
function UserProfile({ userId }: { userId: string }) {
const result = useFetch<User>(`/api/users/${userId}`);
switch (result.status) {
case 'idle': return null;
case 'loading': return <Skeleton />;
case 'error': return <ErrorMessage error={result.error} />;
case 'success': return <div className="profile">{result.data.name}</div>;
}
}
Why Discriminated Unions Over Flags?
// Common but problematic: three independent boolean/nullable fields
type BadState = {
data: T | null;
loading: boolean;
error: Error | null;
};
// Allows impossible states: { data: user, loading: true, error: someError }
// Forces consumers to write defensive null checks everywhere
// TypeScript can't help narrow the type based on loading state
// Better: discriminated union โ mutually exclusive states
type GoodState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T } // data only exists when successful
| { status: 'error'; error: Error }; // error only exists when failed
// TypeScript enforces correctness at compile time
// Pattern matching on status is exhaustive and type-safe
The discriminated union approach makes impossible states unrepresentable, shifts runtime defensive checks to compile-time type narrowing, and makes the component code read as a clean state machine.
useLocalStorage: Serialization, SSR Safety, and Tab Sync
function useLocalStorage<T>(
key: string,
defaultValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
// SSR safety: no window on the server
if (typeof window === 'undefined') return defaultValue;
try {
const item = window.localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : defaultValue;
} catch {
// Handles: JSON.parse failure, localStorage disabled (private mode)
return defaultValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const nextValue = typeof value === 'function'
? (value as (prev: T) => T)(prev)
: value;
try {
window.localStorage.setItem(key, JSON.stringify(nextValue));
} catch {
// localStorage quota exceeded or disabled
console.warn(`useLocalStorage: failed to persist key "${key}"`);
}
return nextValue;
});
},
[key]
);
// Cross-tab synchronization via the storage event
useEffect(() => {
function handleStorageChange(event: StorageEvent) {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue) as T);
} catch {
// Ignore invalid JSON from other tabs
}
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue];
}
// Usage โ API is intentionally identical to useState
function ThemeSettings() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('app-theme', 'light');
return (
<select value={theme} onChange={e => setTheme(e.target.value as 'light' | 'dark')}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
Key design decisions: lazy initialization reads from localStorage only once at mount; the setter mirrors the useState functional update API for consistency; cross-tab sync via the storage event works because this event fires in all tabs except the one that made the change.
useDebounce: Correct Debouncing Implementation
function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
// Cleanup cancels the previous timer before setting the new one
// This is what makes it a debounce rather than a throttle
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}
// Composing useFetch and useDebounce for search-as-you-type
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// Only fetches after 300ms of typing inactivity
// Passes null when query is empty โ useFetch stays idle
const results = useFetch<SearchResult[]>(
debouncedQuery.trim()
? `/api/search?q=${encodeURIComponent(debouncedQuery.trim())}`
: null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{results.status === 'loading' && <Spinner />}
{results.status === 'success' && (
<ul>
{results.data.map(r => (
<li key={r.id}>{r.title}</li>
))}
</ul>
)}
{results.status === 'error' && <p>Search failed. Try again.</p>}
</div>
);
}
The debounce mechanic is entirely in the cleanup: each value change cancels the previous timer and starts a new one. Only when value stops changing for delayMs does the timer fire and update debouncedValue. This is a minimal, correct debounce with no external library dependency.
useWindowSize: Reactive Dimension Detection
type WindowSize = { width: number; height: number };
function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>(() => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
}));
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty deps: only sets up listener once
return size;
}
// In practice, debounce resize events to avoid excessive re-renders
function useBreakpoint(): 'sm' | 'md' | 'lg' | 'xl' {
const { width } = useWindowSize();
// Computed during render โ no extra state or effect needed
if (width < 640) return 'sm';
if (width < 768) return 'md';
if (width < 1024) return 'lg';
return 'xl';
}
function AdaptiveLayout({ children }: { children: React.ReactNode }) {
const breakpoint = useBreakpoint();
return (
<div className={`layout layout-${breakpoint}`}>
{children}
</div>
);
}
useIntersectionObserver: Lazy Loading and Infinite Scroll
type IntersectionConfig = {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
once?: boolean; // Stop observing after first intersection
};
function useIntersectionObserver(
ref: React.RefObject<Element | null>,
config: IntersectionConfig = {}
): boolean {
const [isIntersecting, setIsIntersecting] = useState(false);
const { root, rootMargin, threshold, once } = config;
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting && once) {
observer.disconnect(); // Only trigger once โ useful for lazy loading
}
},
{ root, rootMargin, threshold }
);
observer.observe(element);
return () => observer.disconnect();
}, [ref, root, rootMargin, threshold, once]);
return isIntersecting;
}
// Lazy image loading: render only when visible
function LazyImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
// once: true means the image loads on first scroll-in and stays loaded
const isVisible = useIntersectionObserver(ref, { threshold: 0.1, once: true });
return (
<div ref={ref} className={`lazy-image ${className ?? ''}`}>
{isVisible ? (
<img src={src} alt={alt} loading="lazy" />
) : (
<div className="image-placeholder" aria-hidden="true" />
)}
</div>
);
}
// Infinite scroll: load more when the sentinel element enters the viewport
function InfiniteList<T extends { id: string }>({
fetchPage,
renderItem,
}: {
fetchPage: (page: number) => Promise<T[]>;
renderItem: (item: T) => React.ReactNode;
}) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
const isSentinelVisible = useIntersectionObserver(sentinelRef, { threshold: 1 });
useEffect(() => {
if (isSentinelVisible && hasMore) {
setPage(prev => prev + 1);
}
}, [isSentinelVisible, hasMore]);
useEffect(() => {
fetchPage(page).then(newItems => {
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
}
});
}, [page, fetchPage]);
return (
<div>
{items.map(item => (
<React.Fragment key={item.id}>{renderItem(item)}</React.Fragment>
))}
{hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
</div>
);
}
Design Principles for Custom Hooks
Principle 1: Single Responsibility
Each Hook should solve exactly one problem. If you find yourself naming it useUserDashboard or usePageSetup, it is probably doing too much:
// Too much: three unrelated concerns bundled together
function useUserDashboard(userId: string) {
const userData = useFetch<User>(`/api/users/${userId}`);
const [theme, setTheme] = useLocalStorage('theme', 'light');
const windowSize = useWindowSize();
return { userData, theme, setTheme, windowSize }; // call these separately in the component
}
// Right: compose independent hooks at the call site
function Dashboard({ userId }: { userId: string }) {
const userData = useFetch<User>(`/api/users/${userId}`);
const [theme, setTheme] = useLocalStorage('theme', 'light');
const { width } = useWindowSize();
// Each Hook has a clear single purpose; composition is explicit
}
Principle 2: Consistent Return Value Shape
// Inconsistent: positional array makes the return order load-bearing
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
return [value, () => setValue(v => !v), () => setValue(false)];
// Caller must remember position 0 = value, 1 = toggle, 2 = reset
}
// Consistent: named object, self-documenting, easy to extend
function useToggle(initial = false) {
const [isOn, setIsOn] = useState(initial);
const toggle = useCallback(() => setIsOn(v => !v), []);
const turnOn = useCallback(() => setIsOn(true), []);
const turnOff = useCallback(() => setIsOn(false), []);
const reset = useCallback(() => setIsOn(initial), [initial]);
return { isOn, toggle, turnOn, turnOff, reset };
}
// Usage is clear without needing to check the implementation
const { isOn: isMenuOpen, toggle: toggleMenu } = useToggle(false);
Naming conventions: use prefix is required (enables Hook lint rules). Return named objects rather than positional arrays (except where tuple semantics are obvious, like useState). Verb names for actions (toggle, increment, reset), noun names for values (count, isLoading, data).
Principle 3: Handle Dependencies Correctly
The hardest part of writing correct custom Hooks is getting the dependency story right โ especially when callers pass functions or objects as arguments.
// Bug: handler creates a new function on every render
// The effect re-runs on every render even when event type hasn't changed
function useEventListener(event: string, handler: (e: Event) => void) {
useEffect(() => {
document.addEventListener(event, handler);
return () => document.removeEventListener(event, handler);
}, [event, handler]); // handler is a new function every render
}
// Fix: stabilize handler via ref, keep effect stable
function useEventListener(event: string, handler: (e: Event) => void) {
const handlerRef = useRef(handler);
// Update during render (before effects) so the ref always reflects the latest handler
handlerRef.current = handler;
useEffect(() => {
function stableHandler(e: Event) {
handlerRef.current(e); // Always calls the latest handler
}
document.addEventListener(event, stableHandler);
return () => document.removeEventListener(event, stableHandler);
}, [event]); // Only re-subscribe when the event type changes
}
This pattern โ ref.current = handler in render, stable function in effect โ is foundational for any Hook that accepts a callback as a parameter. The caller does not need to useCallback their handler; the Hook handles stability internally.
Testing Custom Hooks
One of the strongest arguments for extracting custom Hooks is testability. A Hook's stateful logic can be tested directly with renderHook from @testing-library/react, without rendering any UI at all:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
import { useDebounce } from './useDebounce';
describe('useCounter', () => {
it('initializes with the provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments by 1', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('resets to the initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
describe('useDebounce', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('returns the initial value before the delay expires', () => {
const { result } = renderHook(() => useDebounce('initial', 300));
expect(result.current).toBe('initial');
});
it('updates the value after the delay', () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebounce(value, 300),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
expect(result.current).toBe('initial'); // Not yet
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe('updated'); // Now updated
});
it('resets the delay when value changes before expiry', () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
act(() => jest.advanceTimersByTime(200));
rerender({ value: 'abc' });
act(() => jest.advanceTimersByTime(200));
expect(result.current).toBe('a'); // Still the initial โ delay keeps resetting
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe('abc'); // Final value after last 300ms
});
});
The ability to test stateful logic without rendering a component is a genuine advantage of Hooks over class component methods and higher-order components. Fast, focused unit tests for the logic layer, separate from integration tests for the UI layer.
Composing Custom Hooks
The real power of custom Hooks emerges when you compose them. Each small Hook does one thing well; composition handles the complex cases:
// A search feature composed from primitive Hooks
function useProductSearch(catalogId: string) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 350);
const [sortBy, setSortBy] = useLocalStorage<'price' | 'rating'>('sort', 'rating');
const searchUrl = debouncedQuery
? `/api/catalogs/${catalogId}/search?q=${encodeURIComponent(debouncedQuery)}&sort=${sortBy}`
: null;
const results = useFetch<Product[]>(searchUrl);
return { query, setQuery, sortBy, setSortBy, results };
}
// Component stays clean โ all logic is in the Hook
function ProductSearchPage({ catalogId }: { catalogId: string }) {
const { query, setQuery, sortBy, setSortBy, results } = useProductSearch(catalogId);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'price' | 'rating')}>
<option value="rating">Top Rated</option>
<option value="price">Lowest Price</option>
</select>
{results.status === 'loading' && <Spinner />}
{results.status === 'success' && (
<ProductGrid products={results.data} />
)}
</div>
);
}
The component reads as a description of what happens; the Hooks handle how it works. The feature's logic is testable independently of its presentation.
Summary
Custom Hooks are the primary abstraction mechanism for stateful logic in React. Unlike higher-order components and render props โ which required wrapping components in other components โ Hooks extract logic into plain functions. No extra component nesting, no prop namespace conflicts, full TypeScript inference.
The rules of Hooks exist because of the linked list structure in Fiber: React matches Hook calls to nodes by position, so the call order must be stable across renders. Custom Hooks do not change these rules โ they extend them, because a custom Hook is just a function that calls Hooks.
The six Hooks built in this chapter โ useFetch, useLocalStorage, useDebounce, useWindowSize, useIntersectionObserver, and the design patterns around useEventListener โ cover a substantial fraction of real application needs. More importantly, they demonstrate the design principles: single responsibility, consistent API shape, correct dependency handling, and composability. Apply these principles and the specific Hooks you need to write for your application will follow naturally.