React Custom Hooks: Build Reusable Logic with Practical Examples
Custom hooks are one of the most powerful patterns in React. They let you extract stateful logic from components, share it across the codebase, and test it in isolation. The difference between a developer who writes useEffect directly in every component and one who builds clean custom hooks is a significant leap in code quality. This guide teaches by example.
What Makes a Custom Hook?
A custom hook is a JavaScript function that:
- Starts with
use(the naming convention that lets React enforce hook rules) - Calls one or more built-in React hooks
- Returns whatever is useful to the calling component
javascript-- Not a custom hook: just a utility function function formatDate(date) { return new Intl.DateTimeFormat("en-US").format(date); } -- A custom hook: uses useState function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); const reset = () => setCount(initialValue); return { count, increment, decrement, reset }; }
useFetch: Data Fetching with Loading and Error State
javascriptfunction useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function fetchData() { try { setLoading(true); setError(null); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const json = await response.json(); if (!cancelled) setData(json); } catch (err) { if (!cancelled) setError(err.message); } finally { if (!cancelled) setLoading(false); } } fetchData(); return () => { cancelled = true; }; -- cleanup: prevent state update on unmount }, [url]); return { data, loading, error }; } -- Usage function UserProfile({ userId }) { const { data: user, loading, error } = useFetch(`/api/users/${userId}`); if (loading) return <Spinner />; if (error) return <ErrorMessage message={error} />; return <div>{user.name}</div>; }
The cancelled flag prevents setting state on an unmounted component β a common source of React warnings.
useDebounce: Delay Rapidly Changing Values
javascriptfunction useDebounce(value, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timer); -- cancel previous timer on value change }, [value, delay]); return debouncedValue; } -- Usage: search input that only triggers API call after user stops typing function SearchBar() { const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, 400); useEffect(() => { if (debouncedQuery) { searchApi(debouncedQuery); -- only called 400ms after last keystroke } }, [debouncedQuery]); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." /> ); }
useLocalStorage: Persistent State
javascriptfunction useLocalStorage(key, initialValue) { const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } }); const setValue = (value) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { console.error("useLocalStorage error:", error); } }; const removeValue = () => { try { setStoredValue(initialValue); window.localStorage.removeItem(key); } catch (error) { console.error("useLocalStorage error:", error); } }; return [storedValue, setValue, removeValue]; } -- Usage: drop-in replacement for useState with persistence function ThemeSwitcher() { const [theme, setTheme] = useLocalStorage("theme", "light"); return ( <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}> Current: {theme} </button> ); }
useIntersectionObserver: Detect Visibility
javascriptfunction useIntersectionObserver(options = {}) { const [isIntersecting, setIsIntersecting] = useState(false); const [entry, setEntry] = useState(null); const ref = useRef(null); useEffect(() => { const element = ref.current; if (!element) return; const observer = new IntersectionObserver(([entry]) => { setIsIntersecting(entry.isIntersecting); setEntry(entry); }, options); observer.observe(element); return () => observer.disconnect(); }, [options.threshold, options.root, options.rootMargin]); return { ref, isIntersecting, entry }; } -- Usage 1: lazy load images function LazyImage({ src, alt }) { const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.1 }); return ( <img ref={ref} src={isIntersecting ? src : undefined} alt={alt} /> ); } -- Usage 2: animate elements when they scroll into view function AnimatedCard({ children }) { const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.2 }); return ( <div ref={ref} className={isIntersecting ? "card card--visible" : "card card--hidden"} > {children} </div> ); }
useMediaQuery: Responsive Logic in JavaScript
javascriptfunction useMediaQuery(query) { const [matches, setMatches] = useState( () => window.matchMedia(query).matches ); useEffect(() => { const mediaQuery = window.matchMedia(query); const handler = (e) => setMatches(e.matches); mediaQuery.addEventListener("change", handler); return () => mediaQuery.removeEventListener("change", handler); }, [query]); return matches; } -- Usage function Navigation() { const isMobile = useMediaQuery("(max-width: 768px)"); const isDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); if (isMobile) return <MobileNav />; return <DesktopNav />; }
useForm: Form State Management
javascriptfunction useForm(initialValues, validate) { const [values, setValues] = useState(initialValues); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const handleChange = (e) => { const { name, value, type, checked } = e.target; setValues(prev => ({ ...prev, [name]: type === "checkbox" ? checked : value, })); }; const handleBlur = (e) => { const { name } = e.target; setTouched(prev => ({ ...prev, [name]: true })); if (validate) { const validationErrors = validate(values); setErrors(validationErrors); } }; const handleSubmit = (onSubmit) => async (e) => { e.preventDefault(); const allTouched = Object.keys(values).reduce( (acc, key) => ({ ...acc, [key]: true }), {} ); setTouched(allTouched); if (validate) { const validationErrors = validate(values); setErrors(validationErrors); if (Object.keys(validationErrors).length > 0) return; } setIsSubmitting(true); try { await onSubmit(values); } finally { setIsSubmitting(false); } }; const reset = () => { setValues(initialValues); setErrors({}); setTouched({}); }; return { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit, reset, }; } -- Usage function LoginForm({ onLogin }) { const { values, errors, touched, isSubmitting, handleChange, handleBlur, handleSubmit } = useForm( { email: "", password: "" }, ({ email, password }) => { const errors = {}; if (!email) errors.email = "Email is required"; if (!password || password.length < 8) errors.password = "Password too short"; return errors; } ); return ( <form onSubmit={handleSubmit(onLogin)}> <input name="email" value={values.email} onChange={handleChange} onBlur={handleBlur} /> {touched.email && errors.email && <span>{errors.email}</span>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? "Signing in..." : "Sign in"} </button> </form> ); }
Custom Hook Best Practices
Return objects, not arrays (unless the hook is intentionally like useState):
javascript-- Prefer: named returns are self-documenting const { data, loading, error } = useFetch(url); -- Avoid for complex hooks: positional returns are confusing const [data, loading, error, refetch] = useFetch(url);
Stabilize dependencies β use useCallback and useMemo for values passed as deps to prevent infinite loops:
javascriptfunction useFetch(url, options) { -- Bad: new object every render causes infinite loop useEffect(() => { fetch(url, options) }, [url, options]); -- Good: memoize options const stableOptions = useMemo(() => options, [JSON.stringify(options)]); useEffect(() => { fetch(url, stableOptions) }, [url, stableOptions]); }
Test hooks with renderHook:
javascriptimport { renderHook, act } from "@testing-library/react"; test("useCounter increments", () => { const { result } = renderHook(() => useCounter(0)); expect(result.current.count).toBe(0); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); });
Common Interview Questions
Q: What is the naming convention for custom hooks and why does it matter?
Custom hooks must start with use. This is not just convention β React's linter (eslint-plugin-react-hooks) uses it to know where to enforce the rules of hooks (no hooks in conditionals, loops, or non-hook functions). If you name a hook without use, the linter cannot warn you when you violate the rules inside it.
Q: How is a custom hook different from a regular utility function?
A utility function is pure JavaScript. A custom hook calls React hooks (useState, useEffect, useRef, etc.) and therefore must follow the rules of hooks. The key benefit of a hook over a utility function is that it can encapsulate reactive state β it can have its own state and side effects that are tied to the component lifecycle.
Q: How do you prevent a useFetch hook from setting state after unmount?
Use a cleanup flag: set a cancelled variable to true in the useEffect cleanup function. Check if (!cancelled) before calling setState. This prevents the "Can't perform a React state update on an unmounted component" warning and avoids potential memory leaks.
Practice React on Froquiz
Custom hooks and React patterns are tested in mid-level and senior frontend interviews. Test your React knowledge on Froquiz β covering hooks, rendering, performance, and state management.
Summary
- Custom hooks extract stateful logic into reusable, testable functions starting with
use useFetch: manage async data with loading/error states and cleanup on unmountuseDebounce: delay rapidly changing values to prevent excessive API callsuseLocalStorage: persist state across sessions with auseState-compatible APIuseIntersectionObserver: detect element visibility for lazy loading and animationsuseMediaQuery: consume responsive breakpoints in JavaScript logic- Return objects from complex hooks for self-documenting destructuring
- Test hooks with
renderHookfrom@testing-library/react