Hook Patterns and Best Practices
Let's explore advanced patterns for building robust, reusable custom hooks.
Initialization Patterns
Lazy Initialization
Avoid expensive computations on every render:
jsx1// Bad - Runs JSON.parse on every render2function useBad() {3 const [value, setValue] = useState(4 JSON.parse(localStorage.getItem('key') || '{}')5 );6}78// Good - Runs once via function9function useGood() {10 const [value, setValue] = useState(() =>11 JSON.parse(localStorage.getItem('key') || '{}')12 );13}
Conditional Initial Values
Handle different initialization scenarios:
jsx1function usePersistedState(key, defaultValue) {2 const [state, setState] = useState(() => {3 // Check if running on server (SSR)4 if (typeof window === 'undefined') {5 return defaultValue;6 }78 try {9 const stored = localStorage.getItem(key);10 return stored !== null ? JSON.parse(stored) : defaultValue;11 } catch {12 return defaultValue;13 }14 });1516 useEffect(() => {17 localStorage.setItem(key, JSON.stringify(state));18 }, [key, state]);1920 return [state, setState];21}
Event Handler Hooks
useEventListener
A flexible event listener hook:
jsx1function useEventListener(eventName, handler, element = window) {2 // Store handler in ref to avoid re-subscribing3 const savedHandler = useRef(handler);45 // Update ref when handler changes6 useEffect(() => {7 savedHandler.current = handler;8 }, [handler]);910 useEffect(() => {11 const targetElement = element?.current ?? element;1213 if (!targetElement?.addEventListener) return;1415 const eventListener = (event) => savedHandler.current(event);1617 targetElement.addEventListener(eventName, eventListener);1819 return () => {20 targetElement.removeEventListener(eventName, eventListener);21 };22 }, [eventName, element]);23}2425// Usage26function Component() {27 const [coords, setCoords] = useState({ x: 0, y: 0 });2829 useEventListener('mousemove', (e) => {30 setCoords({ x: e.clientX, y: e.clientY });31 });3233 return <p>Mouse: {coords.x}, {coords.y}</p>;34}
useKeyPress
Detect specific key presses:
jsx1function useKeyPress(targetKey) {2 const [keyPressed, setKeyPressed] = useState(false);34 useEffect(() => {5 const downHandler = ({ key }) => {6 if (key === targetKey) setKeyPressed(true);7 };89 const upHandler = ({ key }) => {10 if (key === targetKey) setKeyPressed(false);11 };1213 window.addEventListener('keydown', downHandler);14 window.addEventListener('keyup', upHandler);1516 return () => {17 window.removeEventListener('keydown', downHandler);18 window.removeEventListener('keyup', upHandler);19 };20 }, [targetKey]);2122 return keyPressed;23}2425// Usage - Keyboard shortcuts26function Editor() {27 const isSaving = useKeyPress('s');28 const ctrlPressed = useKeyPress('Control');2930 useEffect(() => {31 if (isSaving && ctrlPressed) {32 saveDocument();33 }34 }, [isSaving, ctrlPressed]);35}
DOM Measurement Hooks
useElementSize
Track element dimensions:
jsx1function useElementSize() {2 const [size, setSize] = useState({ width: 0, height: 0 });3 const elementRef = useRef(null);45 useEffect(() => {6 const element = elementRef.current;7 if (!element) return;89 const resizeObserver = new ResizeObserver((entries) => {10 const { width, height } = entries[0].contentRect;11 setSize({ width, height });12 });1314 resizeObserver.observe(element);1516 return () => resizeObserver.disconnect();17 }, []);1819 return [elementRef, size];20}2122// Usage23function ResponsiveChart() {24 const [containerRef, { width, height }] = useElementSize();2526 return (27 <div ref={containerRef} className="chart-container">28 <Chart width={width} height={height} data={data} />29 </div>30 );31}
useIntersectionObserver
Detect when element is visible:
jsx1function useIntersectionObserver(options = {}) {2 const [isIntersecting, setIsIntersecting] = useState(false);3 const [entry, setEntry] = useState(null);4 const elementRef = useRef(null);56 useEffect(() => {7 const element = elementRef.current;8 if (!element) return;910 const observer = new IntersectionObserver(([entry]) => {11 setIsIntersecting(entry.isIntersecting);12 setEntry(entry);13 }, options);1415 observer.observe(element);1617 return () => observer.disconnect();18 }, [options.threshold, options.root, options.rootMargin]);1920 return [elementRef, isIntersecting, entry];21}2223// Usage - Lazy loading images24function LazyImage({ src, alt }) {25 const [ref, isVisible] = useIntersectionObserver({26 threshold: 0.1,27 rootMargin: '100px'28 });2930 return (31 <div ref={ref}>32 {isVisible ? (33 <img src={src} alt={alt} />34 ) : (35 <div className="placeholder" />36 )}37 </div>38 );39}
State Machine Hooks
useStateMachine
Manage complex state transitions:
jsx1function useStateMachine(initialState, transitions) {2 const [state, setState] = useState(initialState);34 const send = useCallback((event) => {5 setState((currentState) => {6 const nextState = transitions[currentState]?.[event];7 return nextState ?? currentState;8 });9 }, [transitions]);1011 return [state, send];12}1314// Usage - Form submission15const formTransitions = {16 idle: { SUBMIT: 'submitting' },17 submitting: { SUCCESS: 'success', ERROR: 'error' },18 success: { RESET: 'idle' },19 error: { RETRY: 'submitting', RESET: 'idle' }20};2122function Form() {23 const [state, send] = useStateMachine('idle', formTransitions);2425 const handleSubmit = async () => {26 send('SUBMIT');27 try {28 await submitForm();29 send('SUCCESS');30 } catch {31 send('ERROR');32 }33 };3435 return (36 <form>37 {state === 'idle' && <button onClick={handleSubmit}>Submit</button>}38 {state === 'submitting' && <Spinner />}39 {state === 'success' && <p>Done! <button onClick={() => send('RESET')}>Reset</button></p>}40 {state === 'error' && <p>Error! <button onClick={() => send('RETRY')}>Retry</button></p>}41 </form>42 );43}
Async Hooks
useAsync
Generic async operation handler:
jsx1function useAsync(asyncFunction, immediate = true) {2 const [status, setStatus] = useState('idle');3 const [value, setValue] = useState(null);4 const [error, setError] = useState(null);56 const execute = useCallback(async (...args) => {7 setStatus('pending');8 setValue(null);9 setError(null);1011 try {12 const response = await asyncFunction(...args);13 setValue(response);14 setStatus('success');15 return response;16 } catch (error) {17 setError(error);18 setStatus('error');19 throw error;20 }21 }, [asyncFunction]);2223 useEffect(() => {24 if (immediate) {25 execute();26 }27 }, [execute, immediate]);2829 return {30 execute,31 status,32 value,33 error,34 isIdle: status === 'idle',35 isPending: status === 'pending',36 isSuccess: status === 'success',37 isError: status === 'error'38 };39}4041// Usage42function UserProfile({ userId }) {43 const {44 execute: fetchUser,45 value: user,46 isPending,47 isError48 } = useAsync(() => api.getUser(userId), true);4950 if (isPending) return <Spinner />;51 if (isError) return <button onClick={fetchUser}>Retry</button>;5253 return <div>{user.name}</div>;54}
useMutation
Handle data mutations with optimistic updates:
jsx1function useMutation(mutationFn) {2 const [state, setState] = useState({3 status: 'idle',4 data: null,5 error: null6 });78 const mutate = useCallback(async (variables, options = {}) => {9 setState({ status: 'pending', data: null, error: null });1011 try {12 const data = await mutationFn(variables);13 setState({ status: 'success', data, error: null });14 options.onSuccess?.(data);15 return data;16 } catch (error) {17 setState({ status: 'error', data: null, error });18 options.onError?.(error);19 throw error;20 }21 }, [mutationFn]);2223 const reset = useCallback(() => {24 setState({ status: 'idle', data: null, error: null });25 }, []);2627 return {28 mutate,29 reset,30 ...state,31 isIdle: state.status === 'idle',32 isPending: state.status === 'pending',33 isSuccess: state.status === 'success',34 isError: state.status === 'error'35 };36}3738// Usage39function DeleteButton({ itemId, onDeleted }) {40 const { mutate, isPending } = useMutation(41 (id) => api.deleteItem(id)42 );4344 return (45 <button46 disabled={isPending}47 onClick={() => mutate(itemId, { onSuccess: onDeleted })}48 >49 {isPending ? 'Deleting...' : 'Delete'}50 </button>51 );52}
Interval and Timer Hooks
useInterval
Declarative setInterval:
jsx1function useInterval(callback, delay) {2 const savedCallback = useRef(callback);34 // Remember the latest callback5 useEffect(() => {6 savedCallback.current = callback;7 }, [callback]);89 // Set up the interval10 useEffect(() => {11 if (delay === null) return;1213 const id = setInterval(() => savedCallback.current(), delay);14 return () => clearInterval(id);15 }, [delay]);16}1718// Usage - Auto-refresh19function Dashboard() {20 const [data, setData] = useState(null);21 const [isPolling, setIsPolling] = useState(true);2223 useInterval(24 () => fetchDashboardData().then(setData),25 isPolling ? 5000 : null // Pass null to pause26 );2728 return (29 <div>30 <button onClick={() => setIsPolling(!isPolling)}>31 {isPolling ? 'Pause' : 'Resume'} Auto-refresh32 </button>33 <DashboardView data={data} />34 </div>35 );36}
useTimeout
Declarative setTimeout:
jsx1function useTimeout(callback, delay) {2 const savedCallback = useRef(callback);34 useEffect(() => {5 savedCallback.current = callback;6 }, [callback]);78 useEffect(() => {9 if (delay === null) return;1011 const id = setTimeout(() => savedCallback.current(), delay);12 return () => clearTimeout(id);13 }, [delay]);14}1516// Usage - Auto-dismiss notification17function Notification({ message, duration = 3000, onDismiss }) {18 useTimeout(onDismiss, duration);1920 return (21 <div className="notification">22 {message}23 <button onClick={onDismiss}>Dismiss</button>24 </div>25 );26}
Testing Custom Hooks
Use @testing-library/react-hooks or renderHook:
jsx1import { renderHook, act } from '@testing-library/react';2import { useCounter } from './useCounter';34describe('useCounter', () => {5 test('initializes with default value', () => {6 const { result } = renderHook(() => useCounter());7 expect(result.current.count).toBe(0);8 });910 test('initializes with custom value', () => {11 const { result } = renderHook(() => useCounter(10));12 expect(result.current.count).toBe(10);13 });1415 test('increments count', () => {16 const { result } = renderHook(() => useCounter(0));1718 act(() => {19 result.current.increment();20 });2122 expect(result.current.count).toBe(1);23 });2425 test('decrements count', () => {26 const { result } = renderHook(() => useCounter(5));2728 act(() => {29 result.current.decrement();30 });3132 expect(result.current.count).toBe(4);33 });34});
Hook Organization
Structure hooks in your project:
1src/2 hooks/3 index.js # Re-exports all hooks4 useToggle.js5 useLocalStorage.js6 useFetch.js7 useDebounce.js8 useClickOutside.js9 dom/10 useElementSize.js11 useIntersectionObserver.js12 async/13 useAsync.js14 useMutation.js
Export from index:
jsx1// hooks/index.js2export { useToggle } from './useToggle';3export { useLocalStorage } from './useLocalStorage';4export { useFetch } from './useFetch';5export { useDebounce } from './useDebounce';6// ... etc
Summary
Best practices for custom hooks:
- Use lazy initialization for expensive computations
- Store handlers in refs to avoid re-subscribing
- Return consistent shapes (object vs array based on use case)
- Handle cleanup properly in effects
- Support null/disabled states (pass null to disable)
- Add TypeScript types for better DX
- Write tests using renderHook and act
- Organize hooks by domain or functionality
Common hook categories:
- State hooks - Toggle, form, history
- Effect hooks - Event listeners, observers
- DOM hooks - Size, scroll, intersection
- Async hooks - Fetch, mutation, polling
- Timer hooks - Interval, timeout, debounce
Next, let's build a complete hook library with these patterns!