Back to Blog
ReactHooksCustom HooksPatterns

React Custom Hooks Patterns

Build reusable custom hooks in React. From data fetching to form handling to browser APIs.

B
Bootspring Team
Engineering
April 10, 2021
8 min read

Custom hooks extract and share stateful logic. Here are essential patterns for building them.

useLocalStorage#

1function useLocalStorage<T>(key: string, initialValue: T) { 2 const [storedValue, setStoredValue] = useState<T>(() => { 3 try { 4 const item = window.localStorage.getItem(key); 5 return item ? JSON.parse(item) : initialValue; 6 } catch { 7 return initialValue; 8 } 9 }); 10 11 const setValue = (value: T | ((val: T) => T)) => { 12 try { 13 const valueToStore = 14 value instanceof Function ? value(storedValue) : value; 15 setStoredValue(valueToStore); 16 window.localStorage.setItem(key, JSON.stringify(valueToStore)); 17 } catch (error) { 18 console.error('Error saving to localStorage:', error); 19 } 20 }; 21 22 return [storedValue, setValue] as const; 23} 24 25// Usage 26function Settings() { 27 const [theme, setTheme] = useLocalStorage('theme', 'light'); 28 29 return ( 30 <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}> 31 Current: {theme} 32 </button> 33 ); 34}

useFetch#

1interface UseFetchState<T> { 2 data: T | null; 3 loading: boolean; 4 error: Error | null; 5} 6 7function useFetch<T>(url: string, options?: RequestInit) { 8 const [state, setState] = useState<UseFetchState<T>>({ 9 data: null, 10 loading: true, 11 error: null, 12 }); 13 14 useEffect(() => { 15 const controller = new AbortController(); 16 17 async function fetchData() { 18 setState(prev => ({ ...prev, loading: true })); 19 20 try { 21 const response = await fetch(url, { 22 ...options, 23 signal: controller.signal, 24 }); 25 26 if (!response.ok) { 27 throw new Error(`HTTP error! status: ${response.status}`); 28 } 29 30 const data = await response.json(); 31 setState({ data, loading: false, error: null }); 32 } catch (error) { 33 if (error instanceof Error && error.name !== 'AbortError') { 34 setState({ data: null, loading: false, error }); 35 } 36 } 37 } 38 39 fetchData(); 40 41 return () => controller.abort(); 42 }, [url]); 43 44 return state; 45} 46 47// Usage 48function UserProfile({ userId }: { userId: string }) { 49 const { data, loading, error } = useFetch<User>(`/api/users/${userId}`); 50 51 if (loading) return <Spinner />; 52 if (error) return <Error message={error.message} />; 53 if (!data) return null; 54 55 return <div>{data.name}</div>; 56}

useDebounce#

1function useDebounce<T>(value: T, delay: number): T { 2 const [debouncedValue, setDebouncedValue] = useState(value); 3 4 useEffect(() => { 5 const timer = setTimeout(() => setDebouncedValue(value), delay); 6 return () => clearTimeout(timer); 7 }, [value, delay]); 8 9 return debouncedValue; 10} 11 12// Usage 13function Search() { 14 const [query, setQuery] = useState(''); 15 const debouncedQuery = useDebounce(query, 300); 16 17 useEffect(() => { 18 if (debouncedQuery) { 19 searchAPI(debouncedQuery); 20 } 21 }, [debouncedQuery]); 22 23 return ( 24 <input 25 value={query} 26 onChange={e => setQuery(e.target.value)} 27 placeholder="Search..." 28 /> 29 ); 30}

useToggle#

1function useToggle(initialValue = false) { 2 const [value, setValue] = useState(initialValue); 3 4 const toggle = useCallback(() => setValue(v => !v), []); 5 const setTrue = useCallback(() => setValue(true), []); 6 const setFalse = useCallback(() => setValue(false), []); 7 8 return { value, toggle, setTrue, setFalse } as const; 9} 10 11// Usage 12function Modal() { 13 const { value: isOpen, toggle, setFalse: close } = useToggle(); 14 15 return ( 16 <> 17 <button onClick={toggle}>Open Modal</button> 18 {isOpen && ( 19 <div className="modal"> 20 <button onClick={close}>Close</button> 21 </div> 22 )} 23 </> 24 ); 25}

useClickOutside#

1function useClickOutside<T extends HTMLElement>( 2 handler: () => void 3): RefObject<T> { 4 const ref = useRef<T>(null); 5 6 useEffect(() => { 7 const listener = (event: MouseEvent | TouchEvent) => { 8 if (!ref.current || ref.current.contains(event.target as Node)) { 9 return; 10 } 11 handler(); 12 }; 13 14 document.addEventListener('mousedown', listener); 15 document.addEventListener('touchstart', listener); 16 17 return () => { 18 document.removeEventListener('mousedown', listener); 19 document.removeEventListener('touchstart', listener); 20 }; 21 }, [handler]); 22 23 return ref; 24} 25 26// Usage 27function Dropdown() { 28 const [isOpen, setIsOpen] = useState(false); 29 const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false)); 30 31 return ( 32 <div ref={ref}> 33 <button onClick={() => setIsOpen(true)}>Menu</button> 34 {isOpen && <div className="dropdown-menu">...</div>} 35 </div> 36 ); 37}

useMediaQuery#

1function useMediaQuery(query: string): boolean { 2 const [matches, setMatches] = useState(() => { 3 if (typeof window === 'undefined') return false; 4 return window.matchMedia(query).matches; 5 }); 6 7 useEffect(() => { 8 const mediaQuery = window.matchMedia(query); 9 10 const handler = (event: MediaQueryListEvent) => { 11 setMatches(event.matches); 12 }; 13 14 mediaQuery.addEventListener('change', handler); 15 return () => mediaQuery.removeEventListener('change', handler); 16 }, [query]); 17 18 return matches; 19} 20 21// Usage 22function Sidebar() { 23 const isMobile = useMediaQuery('(max-width: 768px)'); 24 25 return isMobile ? <MobileNav /> : <DesktopSidebar />; 26} 27 28// Predefined hooks 29function useIsMobile() { 30 return useMediaQuery('(max-width: 768px)'); 31} 32 33function usePrefersDarkMode() { 34 return useMediaQuery('(prefers-color-scheme: dark)'); 35}

usePrevious#

1function usePrevious<T>(value: T): T | undefined { 2 const ref = useRef<T>(); 3 4 useEffect(() => { 5 ref.current = value; 6 }, [value]); 7 8 return ref.current; 9} 10 11// Usage 12function Counter() { 13 const [count, setCount] = useState(0); 14 const previousCount = usePrevious(count); 15 16 return ( 17 <div> 18 <p>Current: {count}, Previous: {previousCount ?? 'N/A'}</p> 19 <button onClick={() => setCount(c => c + 1)}>Increment</button> 20 </div> 21 ); 22}

useInterval#

1function useInterval(callback: () => void, delay: number | null) { 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 tick = () => savedCallback.current(); 12 const id = setInterval(tick, delay); 13 14 return () => clearInterval(id); 15 }, [delay]); 16} 17 18// Usage 19function Timer() { 20 const [count, setCount] = useState(0); 21 const [isRunning, setIsRunning] = useState(true); 22 23 useInterval( 24 () => setCount(c => c + 1), 25 isRunning ? 1000 : null 26 ); 27 28 return ( 29 <div> 30 <p>Count: {count}</p> 31 <button onClick={() => setIsRunning(!isRunning)}> 32 {isRunning ? 'Pause' : 'Resume'} 33 </button> 34 </div> 35 ); 36}

useAsync#

1interface UseAsyncState<T> { 2 data: T | null; 3 loading: boolean; 4 error: Error | null; 5} 6 7interface UseAsyncReturn<T> extends UseAsyncState<T> { 8 execute: () => Promise<void>; 9 reset: () => void; 10} 11 12function useAsync<T>( 13 asyncFunction: () => Promise<T>, 14 immediate = false 15): UseAsyncReturn<T> { 16 const [state, setState] = useState<UseAsyncState<T>>({ 17 data: null, 18 loading: immediate, 19 error: null, 20 }); 21 22 const execute = useCallback(async () => { 23 setState({ data: null, loading: true, error: null }); 24 25 try { 26 const data = await asyncFunction(); 27 setState({ data, loading: false, error: null }); 28 } catch (error) { 29 setState({ data: null, loading: false, error: error as Error }); 30 } 31 }, [asyncFunction]); 32 33 const reset = useCallback(() => { 34 setState({ data: null, loading: false, error: null }); 35 }, []); 36 37 useEffect(() => { 38 if (immediate) { 39 execute(); 40 } 41 }, [immediate, execute]); 42 43 return { ...state, execute, reset }; 44} 45 46// Usage 47function UserActions({ userId }: { userId: string }) { 48 const deleteUser = useAsync( 49 () => fetch(`/api/users/${userId}`, { method: 'DELETE' }), 50 false 51 ); 52 53 return ( 54 <button 55 onClick={deleteUser.execute} 56 disabled={deleteUser.loading} 57 > 58 {deleteUser.loading ? 'Deleting...' : 'Delete User'} 59 </button> 60 ); 61}

useForm#

1interface UseFormOptions<T> { 2 initialValues: T; 3 validate?: (values: T) => Partial<Record<keyof T, string>>; 4 onSubmit: (values: T) => void | Promise<void>; 5} 6 7function useForm<T extends Record<string, any>>({ 8 initialValues, 9 validate, 10 onSubmit, 11}: UseFormOptions<T>) { 12 const [values, setValues] = useState(initialValues); 13 const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({}); 14 const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({}); 15 const [isSubmitting, setIsSubmitting] = useState(false); 16 17 const handleChange = ( 18 e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> 19 ) => { 20 const { name, value, type } = e.target; 21 const inputValue = type === 'checkbox' 22 ? (e.target as HTMLInputElement).checked 23 : value; 24 25 setValues(prev => ({ ...prev, [name]: inputValue })); 26 }; 27 28 const handleBlur = ( 29 e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement> 30 ) => { 31 const { name } = e.target; 32 setTouched(prev => ({ ...prev, [name]: true })); 33 34 if (validate) { 35 setErrors(validate(values)); 36 } 37 }; 38 39 const handleSubmit = async (e: React.FormEvent) => { 40 e.preventDefault(); 41 42 if (validate) { 43 const validationErrors = validate(values); 44 setErrors(validationErrors); 45 setTouched( 46 Object.keys(values).reduce( 47 (acc, key) => ({ ...acc, [key]: true }), 48 {} 49 ) as Record<keyof T, boolean> 50 ); 51 52 if (Object.keys(validationErrors).length > 0) return; 53 } 54 55 setIsSubmitting(true); 56 try { 57 await onSubmit(values); 58 } finally { 59 setIsSubmitting(false); 60 } 61 }; 62 63 const reset = () => { 64 setValues(initialValues); 65 setErrors({}); 66 setTouched({}); 67 setIsSubmitting(false); 68 }; 69 70 return { 71 values, 72 errors, 73 touched, 74 isSubmitting, 75 handleChange, 76 handleBlur, 77 handleSubmit, 78 reset, 79 setValues, 80 }; 81} 82 83// Usage 84function LoginForm() { 85 const form = useForm({ 86 initialValues: { email: '', password: '' }, 87 validate: (values) => { 88 const errors: Record<string, string> = {}; 89 if (!values.email) errors.email = 'Required'; 90 if (!values.password) errors.password = 'Required'; 91 return errors; 92 }, 93 onSubmit: async (values) => { 94 await login(values); 95 }, 96 }); 97 98 return ( 99 <form onSubmit={form.handleSubmit}> 100 <input 101 name="email" 102 value={form.values.email} 103 onChange={form.handleChange} 104 onBlur={form.handleBlur} 105 /> 106 {form.touched.email && form.errors.email && ( 107 <span>{form.errors.email}</span> 108 )} 109 <button type="submit" disabled={form.isSubmitting}> 110 Submit 111 </button> 112 </form> 113 ); 114}

useCopyToClipboard#

1function useCopyToClipboard() { 2 const [copied, setCopied] = useState(false); 3 4 const copy = useCallback(async (text: string) => { 5 try { 6 await navigator.clipboard.writeText(text); 7 setCopied(true); 8 setTimeout(() => setCopied(false), 2000); 9 return true; 10 } catch { 11 setCopied(false); 12 return false; 13 } 14 }, []); 15 16 return { copied, copy }; 17} 18 19// Usage 20function ShareButton({ url }: { url: string }) { 21 const { copied, copy } = useCopyToClipboard(); 22 23 return ( 24 <button onClick={() => copy(url)}> 25 {copied ? 'Copied!' : 'Copy Link'} 26 </button> 27 ); 28}

Best Practices#

Design: ✓ Start with 'use' prefix ✓ Return object for multiple values ✓ Use TypeScript generics ✓ Handle cleanup in useEffect Performance: ✓ Memoize callbacks with useCallback ✓ Memoize values with useMemo ✓ Use refs for values that shouldn't trigger rerenders ✓ Return stable references Testing: ✓ Test with renderHook ✓ Test edge cases ✓ Test cleanup ✓ Test error states

Conclusion#

Custom hooks encapsulate reusable stateful logic. Start with common patterns like useLocalStorage and useFetch, then build domain-specific hooks as needed. Always handle cleanup, use TypeScript for type safety, and memoize where appropriate.

Share this article

Help spread the word about Bootspring