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.