Custom hooks extract and share component logic. Here's how to build them effectively.
Basic Custom Hook#
1import { useState, useEffect } from 'react';
2
3// Convention: start with "use"
4function useWindowSize() {
5 const [size, setSize] = useState({
6 width: window.innerWidth,
7 height: window.innerHeight,
8 });
9
10 useEffect(() => {
11 const handleResize = () => {
12 setSize({
13 width: window.innerWidth,
14 height: window.innerHeight,
15 });
16 };
17
18 window.addEventListener('resize', handleResize);
19 return () => window.removeEventListener('resize', handleResize);
20 }, []);
21
22 return size;
23}
24
25// Usage
26function ResponsiveComponent() {
27 const { width, height } = useWindowSize();
28
29 return (
30 <div>
31 Window: {width} x {height}
32 {width < 768 && <MobileNav />}
33 </div>
34 );
35}Hook with Parameters#
1function useLocalStorage<T>(key: string, initialValue: T) {
2 // Lazy initialization
3 const [storedValue, setStoredValue] = useState<T>(() => {
4 if (typeof window === 'undefined') {
5 return initialValue;
6 }
7 try {
8 const item = window.localStorage.getItem(key);
9 return item ? JSON.parse(item) : initialValue;
10 } catch (error) {
11 console.error(error);
12 return initialValue;
13 }
14 });
15
16 // Update localStorage when state changes
17 const setValue = (value: T | ((val: T) => T)) => {
18 try {
19 const valueToStore =
20 value instanceof Function ? value(storedValue) : value;
21 setStoredValue(valueToStore);
22 if (typeof window !== 'undefined') {
23 window.localStorage.setItem(key, JSON.stringify(valueToStore));
24 }
25 } catch (error) {
26 console.error(error);
27 }
28 };
29
30 return [storedValue, setValue] as const;
31}
32
33// Usage
34function Settings() {
35 const [theme, setTheme] = useLocalStorage('theme', 'light');
36 const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
37
38 return (
39 <div>
40 <select value={theme} onChange={(e) => setTheme(e.target.value)}>
41 <option value="light">Light</option>
42 <option value="dark">Dark</option>
43 </select>
44 </div>
45 );
46}Async Hook with Loading State#
1interface AsyncState<T> {
2 data: T | null;
3 loading: boolean;
4 error: Error | null;
5}
6
7function useAsync<T>(asyncFn: () => Promise<T>, deps: any[] = []) {
8 const [state, setState] = useState<AsyncState<T>>({
9 data: null,
10 loading: true,
11 error: null,
12 });
13
14 useEffect(() => {
15 let isMounted = true;
16
17 setState({ data: null, loading: true, error: null });
18
19 asyncFn()
20 .then((data) => {
21 if (isMounted) {
22 setState({ data, loading: false, error: null });
23 }
24 })
25 .catch((error) => {
26 if (isMounted) {
27 setState({ data: null, loading: false, error });
28 }
29 });
30
31 return () => {
32 isMounted = false;
33 };
34 }, deps);
35
36 return state;
37}
38
39// Usage
40function UserProfile({ userId }: { userId: string }) {
41 const { data: user, loading, error } = useAsync(
42 () => fetchUser(userId),
43 [userId]
44 );
45
46 if (loading) return <Spinner />;
47 if (error) return <Error message={error.message} />;
48 return <Profile user={user!} />;
49}Hook Composition#
1// Base hooks
2function useToggle(initialValue = false) {
3 const [value, setValue] = useState(initialValue);
4
5 const toggle = useCallback(() => setValue((v) => !v), []);
6 const setOn = useCallback(() => setValue(true), []);
7 const setOff = useCallback(() => setValue(false), []);
8
9 return { value, toggle, setOn, setOff };
10}
11
12function useClickOutside(
13 ref: React.RefObject<HTMLElement>,
14 handler: () => void
15) {
16 useEffect(() => {
17 const listener = (event: MouseEvent | TouchEvent) => {
18 if (!ref.current || ref.current.contains(event.target as Node)) {
19 return;
20 }
21 handler();
22 };
23
24 document.addEventListener('mousedown', listener);
25 document.addEventListener('touchstart', listener);
26
27 return () => {
28 document.removeEventListener('mousedown', listener);
29 document.removeEventListener('touchstart', listener);
30 };
31 }, [ref, handler]);
32}
33
34// Composed hook
35function useDropdown() {
36 const { value: isOpen, toggle, setOff } = useToggle(false);
37 const ref = useRef<HTMLDivElement>(null);
38
39 useClickOutside(ref, setOff);
40
41 useEffect(() => {
42 const handleEscape = (event: KeyboardEvent) => {
43 if (event.key === 'Escape') {
44 setOff();
45 }
46 };
47
48 if (isOpen) {
49 document.addEventListener('keydown', handleEscape);
50 }
51
52 return () => {
53 document.removeEventListener('keydown', handleEscape);
54 };
55 }, [isOpen, setOff]);
56
57 return { ref, isOpen, toggle, close: setOff };
58}
59
60// Usage
61function Dropdown() {
62 const { ref, isOpen, toggle, close } = useDropdown();
63
64 return (
65 <div ref={ref}>
66 <button onClick={toggle}>Menu</button>
67 {isOpen && (
68 <ul>
69 <li onClick={close}>Item 1</li>
70 <li onClick={close}>Item 2</li>
71 </ul>
72 )}
73 </div>
74 );
75}Reducer-Based Hook#
1interface FormState<T> {
2 values: T;
3 errors: Partial<Record<keyof T, string>>;
4 touched: Partial<Record<keyof T, boolean>>;
5 isSubmitting: boolean;
6}
7
8type FormAction<T> =
9 | { type: 'SET_FIELD'; field: keyof T; value: T[keyof T] }
10 | { type: 'SET_ERROR'; field: keyof T; error: string }
11 | { type: 'TOUCH_FIELD'; field: keyof T }
12 | { type: 'SUBMIT_START' }
13 | { type: 'SUBMIT_END' }
14 | { type: 'RESET'; initialValues: T };
15
16function formReducer<T>(
17 state: FormState<T>,
18 action: FormAction<T>
19): FormState<T> {
20 switch (action.type) {
21 case 'SET_FIELD':
22 return {
23 ...state,
24 values: { ...state.values, [action.field]: action.value },
25 };
26 case 'SET_ERROR':
27 return {
28 ...state,
29 errors: { ...state.errors, [action.field]: action.error },
30 };
31 case 'TOUCH_FIELD':
32 return {
33 ...state,
34 touched: { ...state.touched, [action.field]: true },
35 };
36 case 'SUBMIT_START':
37 return { ...state, isSubmitting: true };
38 case 'SUBMIT_END':
39 return { ...state, isSubmitting: false };
40 case 'RESET':
41 return {
42 values: action.initialValues,
43 errors: {},
44 touched: {},
45 isSubmitting: false,
46 };
47 default:
48 return state;
49 }
50}
51
52function useForm<T extends Record<string, any>>(
53 initialValues: T,
54 validate: (values: T) => Partial<Record<keyof T, string>>,
55 onSubmit: (values: T) => Promise<void>
56) {
57 const [state, dispatch] = useReducer(formReducer<T>, {
58 values: initialValues,
59 errors: {},
60 touched: {},
61 isSubmitting: false,
62 });
63
64 const handleChange = useCallback((field: keyof T, value: T[keyof T]) => {
65 dispatch({ type: 'SET_FIELD', field, value });
66 }, []);
67
68 const handleBlur = useCallback((field: keyof T) => {
69 dispatch({ type: 'TOUCH_FIELD', field });
70
71 const errors = validate(state.values);
72 if (errors[field]) {
73 dispatch({ type: 'SET_ERROR', field, error: errors[field]! });
74 }
75 }, [state.values, validate]);
76
77 const handleSubmit = useCallback(async (e: React.FormEvent) => {
78 e.preventDefault();
79
80 const errors = validate(state.values);
81 const hasErrors = Object.keys(errors).length > 0;
82
83 if (hasErrors) {
84 Object.entries(errors).forEach(([field, error]) => {
85 dispatch({ type: 'SET_ERROR', field: field as keyof T, error: error! });
86 });
87 return;
88 }
89
90 dispatch({ type: 'SUBMIT_START' });
91 try {
92 await onSubmit(state.values);
93 } finally {
94 dispatch({ type: 'SUBMIT_END' });
95 }
96 }, [state.values, validate, onSubmit]);
97
98 return {
99 ...state,
100 handleChange,
101 handleBlur,
102 handleSubmit,
103 reset: () => dispatch({ type: 'RESET', initialValues }),
104 };
105}Testing Custom Hooks#
1import { renderHook, act } from '@testing-library/react';
2
3// Test useCounter
4describe('useCounter', () => {
5 it('should initialize with default value', () => {
6 const { result } = renderHook(() => useCounter(0));
7 expect(result.current.count).toBe(0);
8 });
9
10 it('should increment', () => {
11 const { result } = renderHook(() => useCounter(0));
12
13 act(() => {
14 result.current.increment();
15 });
16
17 expect(result.current.count).toBe(1);
18 });
19
20 it('should handle props changes', () => {
21 const { result, rerender } = renderHook(
22 ({ initialValue }) => useCounter(initialValue),
23 { initialProps: { initialValue: 0 } }
24 );
25
26 expect(result.current.count).toBe(0);
27
28 rerender({ initialValue: 10 });
29 // Depends on hook implementation
30 });
31});
32
33// Test async hook
34describe('useAsync', () => {
35 it('should handle loading state', async () => {
36 const mockFetch = jest.fn().mockResolvedValue({ data: 'test' });
37
38 const { result } = renderHook(() => useAsync(mockFetch, []));
39
40 expect(result.current.loading).toBe(true);
41
42 await waitFor(() => {
43 expect(result.current.loading).toBe(false);
44 expect(result.current.data).toEqual({ data: 'test' });
45 });
46 });
47});Best Practices#
Naming:
✓ Start with "use" prefix
✓ Be descriptive
✓ Match React conventions
✓ Indicate return type
Design:
✓ Single responsibility
✓ Return stable references
✓ Handle cleanup
✓ Support SSR
Performance:
✓ Memoize callbacks
✓ Use lazy initialization
✓ Avoid unnecessary effects
✓ Handle race conditions
Testing:
✓ Test in isolation
✓ Test all states
✓ Test cleanup
✓ Test edge cases
Conclusion#
Custom hooks encapsulate reusable logic cleanly. Start simple, compose smaller hooks into larger ones, and always handle cleanup. Test hooks in isolation using renderHook and consider all states: loading, success, error, and edge cases.