Back to Blog
ReactHooksCustom HooksPatterns

Building Custom React Hooks

Create reusable custom hooks. From basic patterns to composition to testing strategies.

B
Bootspring Team
Engineering
October 19, 2021
6 min read

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.

Share this article

Help spread the word about Bootspring