isolation using @testing-library/react-hooks.
Sweet Spot: Extract logic into a custom hook when it is reused across 2+ components, involves complex side-effect cleanup, or requires cross-cutting concerns (e.g., async state, viewport detection, debouncing). Avoid extraction for trivial, single-use state.
Core Solution
Production-ready implementations following React 18+ concurrent rendering guidelines, TypeScript generics, and strict cleanup protocols.
useLocalStorage
import { useState, useEffect, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent('storage', { key }));
}
return valueToStore;
});
}, [key]);
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue) as T);
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, [key]);
return [storedValue, setValue];
}
useDebounce
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);
}, [value, delay]);
return debouncedValue;
}
useIntersectionObserver
import { useRef, useEffect, useState, RefObject } from 'react';
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
export function useIntersectionObserver(
elementRef: RefObject<Element>,
{ threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false }: UseIntersectionObserverOptions = {}
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const frozen = entry?.isIntersecting && freezeOnceVisible;
useEffect(() => {
const node = elementRef.current;
if (!node || frozen) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
{ threshold, root, rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
}, [elementRef, frozen, threshold, root, rootMargin]);
return entry;
}
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handleChange = () => setMatches(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
usePrevious
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
useAsync
import { useState, useEffect, useCallback, useRef } from 'react';
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useAsync<T>(asyncFunction: () => Promise<T>, immediate = true) {
const [state, setState] = useState<AsyncState<T>>({ data: null, loading: false, error: null });
const cancelToken = useRef(false);
const execute = useCallback(() => {
setState(prev => ({ ...prev, loading: true, error: null }));
cancelToken.current = false;
asyncFunction()
.then(data => {
if (!cancelToken.current) setState({ data, loading: false, error: null });
})
.catch(error => {
if (!cancelToken.current) setState({ data: null, loading: false, error });
});
}, [asyncFunction]);
useEffect(() => {
if (immediate) execute();
return () => { cancelToken.current = true; };
}, [execute, immediate]);
return { ...state, execute };
}
Pitfall Guide
- Stale Closures in Dependency Arrays: Omitting referenced state or props from
useEffect/useCallback dependencies causes hooks to capture outdated values. Always use the exhaustive-deps ESLint rule and functional updaters when state depends on previous values.
- Missing Cleanup Routines: Failing to clear timers, disconnect observers, or remove event listeners on unmount leads to memory leaks and callbacks firing on detached DOM nodes. Every side-effect hook must return a cleanup function.
- SSR Hydration Mismatches: Accessing
window, document, or navigator during initial render breaks server-side rendering. Guard browser-only APIs with typeof window !== 'undefined' or defer execution to useLayoutEffect/useEffect.
- Race Conditions in Async Hooks: Resolving promises after component unmount or ignoring cancellation tokens causes state updates on unmounted components. Implement abort controllers or cancellation flags to safely discard stale responses.
- Over-Abstraction & Hook Sprawl: Extracting trivial logic into hooks increases cognitive load and indirection without reusability benefits. Reserve hooks for logic reused across 2+ components or involving complex lifecycle coordination.
- Direct DOM Mutations Bypassing React: Manipulating elements directly inside hooks circumvents React's reconciliation, causing hydration errors and unpredictable UI states. Always use refs for imperative access and respect React's render cycle.
- Ignoring Concurrent Rendering Constraints: Synchronous DOM reads/writes inside hooks can trigger layout thrashing under React 18's concurrent features. Use
useLayoutEffect for DOM measurements and batch state updates to prevent tearing.
Deliverables
- π Blueprint: React Custom Hook Architecture Map β Decision tree for extracting vs. inlining logic, composition patterns, and state synchronization strategies across concurrent renders.
- β
Checklist: Production-Ready Hook Validation β SSR safety guards, dependency array completeness, cleanup verification, TypeScript generic constraints, and RTL test coverage thresholds.
- βοΈ Configuration Templates: Pre-configured
tsconfig.json for hook libraries, Jest + Testing Library setup for isolated hook testing, and Vite/Rollup bundling config with tree-shaking optimization for npm distribution.