FroquizFroquiz
HomeQuizzesSenior ChallengeGet CertifiedBlogAbout
Sign InStart Quiz
Sign InStart Quiz
Froquiz

The most comprehensive quiz platform for software engineers. Test yourself with 10000+ questions and advance your career.

LinkedIn

Platform

  • Start Quizzes
  • Topics
  • Blog
  • My Profile
  • Sign In

About

  • About Us
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

Β© 2026 Froquiz. All rights reserved.Built with passion for technology
Blog & Articles

React Custom Hooks: Build Reusable Logic with Practical Examples

Master React custom hooks by building real ones. Covers useFetch, useDebounce, useLocalStorage, useIntersectionObserver, useForm, useMediaQuery, and the rules and patterns behind great hooks.

Yusuf SeyitoğluMarch 17, 20260 views9 min read

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

javascript
function 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

javascript
function 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

javascript
function 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

javascript
function 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

javascript
function 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

javascript
function 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:

javascript
function 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:

javascript
import { 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 unmount
  • useDebounce: delay rapidly changing values to prevent excessive API calls
  • useLocalStorage: persist state across sessions with a useState-compatible API
  • useIntersectionObserver: detect element visibility for lazy loading and animations
  • useMediaQuery: consume responsive breakpoints in JavaScript logic
  • Return objects from complex hooks for self-documenting destructuring
  • Test hooks with renderHook from @testing-library/react

About Author

Yusuf Seyitoğlu

Author β†’

Other Posts

  • Modern JavaScript: ES2020 to ES2024 Features You Should Be UsingMar 17
  • Software Architecture Patterns: MVC, Clean Architecture, Hexagonal and Event-DrivenMar 17
  • PostgreSQL Full-Text Search: tsvector, tsquery, Ranking and Multilingual SearchMar 17
All Blogs