20 minlesson

Hook Patterns and Best Practices

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:

jsx
1// Bad - Runs JSON.parse on every render
2function useBad() {
3 const [value, setValue] = useState(
4 JSON.parse(localStorage.getItem('key') || '{}')
5 );
6}
7
8// Good - Runs once via function
9function useGood() {
10 const [value, setValue] = useState(() =>
11 JSON.parse(localStorage.getItem('key') || '{}')
12 );
13}

Conditional Initial Values

Handle different initialization scenarios:

jsx
1function usePersistedState(key, defaultValue) {
2 const [state, setState] = useState(() => {
3 // Check if running on server (SSR)
4 if (typeof window === 'undefined') {
5 return defaultValue;
6 }
7
8 try {
9 const stored = localStorage.getItem(key);
10 return stored !== null ? JSON.parse(stored) : defaultValue;
11 } catch {
12 return defaultValue;
13 }
14 });
15
16 useEffect(() => {
17 localStorage.setItem(key, JSON.stringify(state));
18 }, [key, state]);
19
20 return [state, setState];
21}

Event Handler Hooks

useEventListener

A flexible event listener hook:

jsx
1function useEventListener(eventName, handler, element = window) {
2 // Store handler in ref to avoid re-subscribing
3 const savedHandler = useRef(handler);
4
5 // Update ref when handler changes
6 useEffect(() => {
7 savedHandler.current = handler;
8 }, [handler]);
9
10 useEffect(() => {
11 const targetElement = element?.current ?? element;
12
13 if (!targetElement?.addEventListener) return;
14
15 const eventListener = (event) => savedHandler.current(event);
16
17 targetElement.addEventListener(eventName, eventListener);
18
19 return () => {
20 targetElement.removeEventListener(eventName, eventListener);
21 };
22 }, [eventName, element]);
23}
24
25// Usage
26function Component() {
27 const [coords, setCoords] = useState({ x: 0, y: 0 });
28
29 useEventListener('mousemove', (e) => {
30 setCoords({ x: e.clientX, y: e.clientY });
31 });
32
33 return <p>Mouse: {coords.x}, {coords.y}</p>;
34}

useKeyPress

Detect specific key presses:

jsx
1function useKeyPress(targetKey) {
2 const [keyPressed, setKeyPressed] = useState(false);
3
4 useEffect(() => {
5 const downHandler = ({ key }) => {
6 if (key === targetKey) setKeyPressed(true);
7 };
8
9 const upHandler = ({ key }) => {
10 if (key === targetKey) setKeyPressed(false);
11 };
12
13 window.addEventListener('keydown', downHandler);
14 window.addEventListener('keyup', upHandler);
15
16 return () => {
17 window.removeEventListener('keydown', downHandler);
18 window.removeEventListener('keyup', upHandler);
19 };
20 }, [targetKey]);
21
22 return keyPressed;
23}
24
25// Usage - Keyboard shortcuts
26function Editor() {
27 const isSaving = useKeyPress('s');
28 const ctrlPressed = useKeyPress('Control');
29
30 useEffect(() => {
31 if (isSaving && ctrlPressed) {
32 saveDocument();
33 }
34 }, [isSaving, ctrlPressed]);
35}

DOM Measurement Hooks

useElementSize

Track element dimensions:

jsx
1function useElementSize() {
2 const [size, setSize] = useState({ width: 0, height: 0 });
3 const elementRef = useRef(null);
4
5 useEffect(() => {
6 const element = elementRef.current;
7 if (!element) return;
8
9 const resizeObserver = new ResizeObserver((entries) => {
10 const { width, height } = entries[0].contentRect;
11 setSize({ width, height });
12 });
13
14 resizeObserver.observe(element);
15
16 return () => resizeObserver.disconnect();
17 }, []);
18
19 return [elementRef, size];
20}
21
22// Usage
23function ResponsiveChart() {
24 const [containerRef, { width, height }] = useElementSize();
25
26 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:

jsx
1function useIntersectionObserver(options = {}) {
2 const [isIntersecting, setIsIntersecting] = useState(false);
3 const [entry, setEntry] = useState(null);
4 const elementRef = useRef(null);
5
6 useEffect(() => {
7 const element = elementRef.current;
8 if (!element) return;
9
10 const observer = new IntersectionObserver(([entry]) => {
11 setIsIntersecting(entry.isIntersecting);
12 setEntry(entry);
13 }, options);
14
15 observer.observe(element);
16
17 return () => observer.disconnect();
18 }, [options.threshold, options.root, options.rootMargin]);
19
20 return [elementRef, isIntersecting, entry];
21}
22
23// Usage - Lazy loading images
24function LazyImage({ src, alt }) {
25 const [ref, isVisible] = useIntersectionObserver({
26 threshold: 0.1,
27 rootMargin: '100px'
28 });
29
30 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:

jsx
1function useStateMachine(initialState, transitions) {
2 const [state, setState] = useState(initialState);
3
4 const send = useCallback((event) => {
5 setState((currentState) => {
6 const nextState = transitions[currentState]?.[event];
7 return nextState ?? currentState;
8 });
9 }, [transitions]);
10
11 return [state, send];
12}
13
14// Usage - Form submission
15const formTransitions = {
16 idle: { SUBMIT: 'submitting' },
17 submitting: { SUCCESS: 'success', ERROR: 'error' },
18 success: { RESET: 'idle' },
19 error: { RETRY: 'submitting', RESET: 'idle' }
20};
21
22function Form() {
23 const [state, send] = useStateMachine('idle', formTransitions);
24
25 const handleSubmit = async () => {
26 send('SUBMIT');
27 try {
28 await submitForm();
29 send('SUCCESS');
30 } catch {
31 send('ERROR');
32 }
33 };
34
35 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:

jsx
1function useAsync(asyncFunction, immediate = true) {
2 const [status, setStatus] = useState('idle');
3 const [value, setValue] = useState(null);
4 const [error, setError] = useState(null);
5
6 const execute = useCallback(async (...args) => {
7 setStatus('pending');
8 setValue(null);
9 setError(null);
10
11 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]);
22
23 useEffect(() => {
24 if (immediate) {
25 execute();
26 }
27 }, [execute, immediate]);
28
29 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}
40
41// Usage
42function UserProfile({ userId }) {
43 const {
44 execute: fetchUser,
45 value: user,
46 isPending,
47 isError
48 } = useAsync(() => api.getUser(userId), true);
49
50 if (isPending) return <Spinner />;
51 if (isError) return <button onClick={fetchUser}>Retry</button>;
52
53 return <div>{user.name}</div>;
54}

useMutation

Handle data mutations with optimistic updates:

jsx
1function useMutation(mutationFn) {
2 const [state, setState] = useState({
3 status: 'idle',
4 data: null,
5 error: null
6 });
7
8 const mutate = useCallback(async (variables, options = {}) => {
9 setState({ status: 'pending', data: null, error: null });
10
11 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]);
22
23 const reset = useCallback(() => {
24 setState({ status: 'idle', data: null, error: null });
25 }, []);
26
27 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}
37
38// Usage
39function DeleteButton({ itemId, onDeleted }) {
40 const { mutate, isPending } = useMutation(
41 (id) => api.deleteItem(id)
42 );
43
44 return (
45 <button
46 disabled={isPending}
47 onClick={() => mutate(itemId, { onSuccess: onDeleted })}
48 >
49 {isPending ? 'Deleting...' : 'Delete'}
50 </button>
51 );
52}

Interval and Timer Hooks

useInterval

Declarative setInterval:

jsx
1function useInterval(callback, delay) {
2 const savedCallback = useRef(callback);
3
4 // Remember the latest callback
5 useEffect(() => {
6 savedCallback.current = callback;
7 }, [callback]);
8
9 // Set up the interval
10 useEffect(() => {
11 if (delay === null) return;
12
13 const id = setInterval(() => savedCallback.current(), delay);
14 return () => clearInterval(id);
15 }, [delay]);
16}
17
18// Usage - Auto-refresh
19function Dashboard() {
20 const [data, setData] = useState(null);
21 const [isPolling, setIsPolling] = useState(true);
22
23 useInterval(
24 () => fetchDashboardData().then(setData),
25 isPolling ? 5000 : null // Pass null to pause
26 );
27
28 return (
29 <div>
30 <button onClick={() => setIsPolling(!isPolling)}>
31 {isPolling ? 'Pause' : 'Resume'} Auto-refresh
32 </button>
33 <DashboardView data={data} />
34 </div>
35 );
36}

useTimeout

Declarative setTimeout:

jsx
1function useTimeout(callback, delay) {
2 const savedCallback = useRef(callback);
3
4 useEffect(() => {
5 savedCallback.current = callback;
6 }, [callback]);
7
8 useEffect(() => {
9 if (delay === null) return;
10
11 const id = setTimeout(() => savedCallback.current(), delay);
12 return () => clearTimeout(id);
13 }, [delay]);
14}
15
16// Usage - Auto-dismiss notification
17function Notification({ message, duration = 3000, onDismiss }) {
18 useTimeout(onDismiss, duration);
19
20 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:

jsx
1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from './useCounter';
3
4describe('useCounter', () => {
5 test('initializes with default value', () => {
6 const { result } = renderHook(() => useCounter());
7 expect(result.current.count).toBe(0);
8 });
9
10 test('initializes with custom value', () => {
11 const { result } = renderHook(() => useCounter(10));
12 expect(result.current.count).toBe(10);
13 });
14
15 test('increments count', () => {
16 const { result } = renderHook(() => useCounter(0));
17
18 act(() => {
19 result.current.increment();
20 });
21
22 expect(result.current.count).toBe(1);
23 });
24
25 test('decrements count', () => {
26 const { result } = renderHook(() => useCounter(5));
27
28 act(() => {
29 result.current.decrement();
30 });
31
32 expect(result.current.count).toBe(4);
33 });
34});

Hook Organization

Structure hooks in your project:

1src/
2 hooks/
3 index.js # Re-exports all hooks
4 useToggle.js
5 useLocalStorage.js
6 useFetch.js
7 useDebounce.js
8 useClickOutside.js
9 dom/
10 useElementSize.js
11 useIntersectionObserver.js
12 async/
13 useAsync.js
14 useMutation.js

Export from index:

jsx
1// hooks/index.js
2export { useToggle } from './useToggle';
3export { useLocalStorage } from './useLocalStorage';
4export { useFetch } from './useFetch';
5export { useDebounce } from './useDebounce';
6// ... etc

Summary

Best practices for custom hooks:

  1. Use lazy initialization for expensive computations
  2. Store handlers in refs to avoid re-subscribing
  3. Return consistent shapes (object vs array based on use case)
  4. Handle cleanup properly in effects
  5. Support null/disabled states (pass null to disable)
  6. Add TypeScript types for better DX
  7. Write tests using renderHook and act
  8. 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!