Intermediate0 questionsFull Guide

Custom hook — Complete React Interview Guide

Learn React custom hooks with real-world examples like useFetch and useForm. Write reusable, clean, and scalable React code.

The Mental Model

A custom hook is a function that starts with "use" and calls other hooks inside it. That is the entire technical definition. The mental model that makes them powerful is different: a custom hook is a way to extract a behaviour — a complete, self-contained piece of logic that lives across multiple renders — and give it a name. Think of built-in hooks as primitive building blocks: useState gives you one memory slot, useEffect gives you one synchronisation channel, useRef gives you one mutable box. Custom hooks let you combine those primitives into something with a name that describes what it does, not how it works. useFetch is not three hooks — it is "fetch data and track its loading and error state." useDebounce is not a useEffect and a useRef — it is "wait until the user stops typing." The name carries intent that three inline hooks never could. The critical distinction from components: custom hooks share logic, not UI. A component returns JSX. A custom hook returns values, setters, refs, or nothing at all. Two components using the same custom hook each get their own completely independent copy of all its state. There is no shared state between them unless you deliberately put state outside the hook. The rule of thumb for when to extract a custom hook: if you catch yourself copying a useEffect plus its associated state and cleanup into a second component, that pattern has a name. Find the name, write the hook, import it in both places.

The Explanation

The rules of hooks — why they exist and what breaks them

Custom hooks must follow the same Rules of Hooks as built-in hooks. These rules exist because React identifies hooks by their call order — the Nth hook call always corresponds to the Nth slot in the fiber's hook linked list. If the order changes between renders, React reads the wrong slot.

// ❌ Rule 1 violation: hooks inside conditions
function useBadHook(condition) {
  if (condition) {
    const [value, setValue] = useState(0)  // sometimes hook 1, sometimes skipped
  }
  const [other, setOther] = useState('')   // sometimes hook 1, sometimes hook 2
  // React reads slots by position — this produces wrong values or crashes
}
 
// ❌ Rule 2 violation: hooks inside loops
function useBadLoop(items) {
  for (const item of items) {
    const [checked, setChecked] = useState(false)  // number of hooks changes with items.length
  }
}
 
// ✅ Correct: hooks always called in same order, same count
function useGoodHook(condition) {
  const [value, setValue] = useState(0)    // always hook 1
  const [other, setOther] = useState('')   // always hook 2
  // Use condition inside effects or render logic, not around the hook call
  useEffect(() => {
    if (condition) doSomething(value)
  }, [condition, value])
}

The use prefix is not just convention — it is the signal the React linter uses to identify hook calls and enforce these rules. A function named fetchData that calls useState inside it will not get linting protection and will silently produce bugs.

State is independent per component instance

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial)
  const increment = useCallback(() => setCount(c => c + 1), [])
  const decrement = useCallback(() => setCount(c => c - 1), [])
  const reset     = useCallback(() => setCount(initial), [initial])
  return { count, increment, decrement, reset }
}
 
// Two components using the same hook — completely independent state
function CounterA() {
  const { count, increment } = useCounter(0)
  return 
}
 
function CounterB() {
  const { count, increment } = useCounter(100)
  return 
}
// CounterA starts at 0. CounterB starts at 100.
// Clicking A's button does not affect B — they each own their state.
// This is fundamentally different from a Context or module-level variable.

The canonical patterns — hooks every senior developer writes

useFetch — data fetching with lifecycle

function useFetch(url) {
  const [data, setData]       = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError]     = useState(null)
 
  useEffect(() => {
    if (!url) return
    let cancelled = false
    const controller = new AbortController()
 
    async function run() {
      try {
        setLoading(true)
        setError(null)
        const res = await fetch(url, { signal: controller.signal })
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const json = await res.json()
        if (!cancelled) setData(json)
      } catch (err) {
        if (err.name !== 'AbortError' && !cancelled) setError(err.message)
      } finally {
        if (!cancelled) setLoading(false)
      }
    }
 
    run()
    return () => { cancelled = true; controller.abort() }
  }, [url])
 
  return { data, loading, error }
}
 
// Usage — replaces 15 lines of useEffect boilerplate with one line
function UserCard({ id }) {
  const { data: user, loading, error } = useFetch(`/api/users/${id}`)
  if (loading) return 
  if (error)   return 
  return 
{user.name}
}

useDebounce — delay a rapidly changing value

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value)
 
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)
  }, [value, delay])
 
  return debounced
}
 
// Usage — search only fires after user stops typing for 300ms
function SearchBar() {
  const [query, setQuery]   = useState('')
  const debouncedQuery      = useDebounce(query, 300)
  const { data: results }   = useFetch(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  )
 
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <Results items={results} />
    </div>
  )
}

useLocalStorage — state that survives page refresh

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    // Lazy init — runs once, reads from localStorage
    try {
      const stored = window.localStorage.getItem(key)
      return stored ? JSON.parse(stored) : initialValue
    } catch {
      return initialValue
    }
  })
 
  const setStoredValue = useCallback((newValue) => {
    try {
      const valueToStore = newValue instanceof Function ? newValue(value) : newValue
      setValue(valueToStore)
      window.localStorage.setItem(key, JSON.stringify(valueToStore))
    } catch (err) {
      console.error('useLocalStorage write failed:', err)
    }
  }, [key, value])
 
  return [value, setStoredValue]
}
 
// Usage — drop-in replacement for useState with persistence
const [theme, setTheme] = useLocalStorage('theme', 'light')

useEventListener — clean event subscription

function useEventListener(eventName, handler, element = window) {
  const handlerRef = useRef(handler)
 
  // Keep ref current without adding handler to effect deps
  useEffect(() => { handlerRef.current = handler })
 
  useEffect(() => {
    if (!element?.addEventListener) return
    const listener = (event) => handlerRef.current(event)
    element.addEventListener(eventName, listener)
    return () => element.removeEventListener(eventName, listener)
  }, [eventName, element])  // handler intentionally omitted — ref handles staleness
}
 
// Usage
function Modal({ onClose }) {
  useEventListener('keydown', (e) => {
    if (e.key === 'Escape') onClose()
  })
  return <div className="modal">...</div>
}

useIntersectionObserver — lazy loading and scroll tracking

function useIntersectionObserver(options = {}) {
  const [isVisible, setIsVisible] = useState(false)
  const ref = useRef(null)
 
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      setIsVisible(entry.isIntersecting)
    }, options)
 
    if (ref.current) observer.observe(ref.current)
    return () => observer.disconnect()
  }, [options.threshold, options.root, options.rootMargin])
 
  return [ref, isVisible]
}
 
// Usage — image only loads when it enters the viewport
function LazyImage({ src, alt }) {
  const [ref, isVisible] = useIntersectionObserver({ threshold: 0.1 })
  return (
    <div ref={ref}>
      {isVisible && <img src={src} alt={alt} />}
    </div>
  )
}

usePrevious — access the previous render's value

function usePrevious(value) {
  const ref = useRef(undefined)
  useEffect(() => { ref.current = value })  // updates AFTER render
  return ref.current  // returns value from BEFORE this render
}
 
// Usage — animate only when count increases, not decreases
function Counter() {
  const [count, setCount] = useState(0)
  const prev = usePrevious(count)
  const direction = prev === undefined ? 'none' : count > prev ? 'up' : 'down'
  return <div data-direction={direction}>{count}</div>
}

useReducerWithMiddleware — extensible state management

function useReducerWithLogger(reducer, initialState) {
  const [state, dispatch] = useReducer(reducer, initialState)
 
  const logDispatch = useCallback((action) => {
    console.group(`Action: ${action.type}`)
    console.log('Before:', state)
    dispatch(action)
    console.groupEnd()
    // Note: 'After' state is available in next render — can use useEffect for that
  }, [state])
 
  return [state, logDispatch]
}

Composing hooks — building on top of other custom hooks

// Hooks compose naturally — custom hooks can call other custom hooks
function useUserProfile(userId) {
  // Composed from two custom hooks
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`)
  const [isFavorite, setIsFavorite]   = useLocalStorage(`favorite_${userId}`, false)
 
  const toggleFavorite = useCallback(() => {
    setIsFavorite(prev => !prev)
  }, [setIsFavorite])
 
  return { user, loading, error, isFavorite, toggleFavorite }
}
 
// Usage — all complexity hidden, clear intent at call site
function UserCard({ userId }) {
  const { user, loading, isFavorite, toggleFavorite } = useUserProfile(userId)
  if (loading) return <Spinner />
  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={toggleFavorite}>{isFavorite ? '★' : '☆'}</button>
    </div>
  )
}

Return shape conventions

// Convention 1: Array — like useState, when consumers usually rename values
const [value, setValue]     = useLocalStorage('key', 'default')
const [isOpen, setIsOpen]   = useToggle(false)
const [debounced]           = useDebounce(query, 300)
 
// Convention 2: Object — when hook returns many values with distinct names
const { data, loading, error, refetch } = useFetch(url)
const { count, increment, decrement, reset } = useCounter(0)
 
// Rule: use array when there are at most 2 values (like useState)
// Use object when there are 3+ values or when names matter for clarity
 
// Convention 3: Tuple with named elements (less common)
const [ref, isVisible] = useIntersectionObserver()
// Two semantically different things — array works here

Testing custom hooks

// Use @testing-library/react's renderHook
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
 
describe('useCounter', () => {
  test('starts at initial value', () => {
    const { result } = renderHook(() => useCounter(5))
    expect(result.current.count).toBe(5)
  })
 
  test('increments correctly', () => {
    const { result } = renderHook(() => useCounter(0))
    act(() => result.current.increment())
    expect(result.current.count).toBe(1)
  })
 
  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10))
    act(() => result.current.increment())
    act(() => result.current.reset())
    expect(result.current.count).toBe(10)
  })
})
// renderHook creates a minimal host component to run your hook inside
// act() wraps state updates — same as wrapping in act() in component tests

Anti-patterns in custom hooks

// ❌ Anti-pattern 1: Returning JSX from a hook — that's a component
function useHeader() {
  return <header>Hello</header>   // WRONG — this is a component, not a hook
}
 
// ❌ Anti-pattern 2: Overloading one hook with too many responsibilities
function useEverything() {
  // auth + data + ui + analytics — too many concerns, impossible to test
}
 
// ❌ Anti-pattern 3: Premature extraction — extracting a hook used in only one place
// If only one component uses it, keep it inline. Extract when reuse occurs or complexity warrants.
 
// ❌ Anti-pattern 4: Not handling cleanup in hooks with subscriptions
function useBadSubscription(id) {
  const [data, setData] = useState(null)
  useEffect(() => {
    socket.on(id, setData)
    // Missing cleanup: return () => socket.off(id, setData)
    // Duplicate listeners accumulate every time id changes
  }, [id])
}
 
// ❌ Anti-pattern 5: Recreating a library hook (reinventing the wheel)
// useQuery, useForm, useAnimation — if a well-maintained library hook exists, use it
// Write custom hooks for business-domain logic, not infrastructure

Common Misconceptions

⚠️

Many developers think custom hooks share state between components — but each component that calls a custom hook gets its own completely isolated copy of all the hook's state and effects. Custom hooks share logic (the code that runs), not state (the values). To share state between components, you need Context, a global store, or state lifted to a common ancestor.

⚠️

Many developers think the "use" prefix is just a naming convention — but it is a contract that enables the React linter's exhaustive-deps and rules-of-hooks checks. A function that starts with "use" is treated as a hook by the linter and React DevTools. Calling hooks inside a non-"use"-prefixed function silently loses linting protection and can cause Rules of Hooks violations that are hard to debug.

⚠️

Many developers think custom hooks must return something — but hooks can return nothing. A hook whose only job is to register an event listener and clean it up (useEventListener, useKeyPress) may return void. The return value is whatever the consuming component needs — sometimes that's nothing.

⚠️

Many developers think custom hooks are an advanced pattern — but they are the primary mechanism for code reuse in hooks-based React. Any time you copy a useEffect with its associated state into a second component, you have missed an opportunity for a custom hook. In a mature codebase, most non-trivial useEffect code should live in named custom hooks.

⚠️

Many developers think hooks can only call built-in React hooks — but custom hooks can call any other hooks, including other custom hooks. This composability is the key to building a layered hook architecture: primitive hooks (useState, useEffect), infrastructure hooks (useFetch, useDebounce), domain hooks (useUserProfile, useCartItem), each layer built on the one below it.

⚠️

Many developers think extracting a custom hook always improves performance — but extraction itself has no performance effect. The same code runs the same way whether it lives inline in a component or in a named hook. The performance benefits of hooks (memoisation, stable references) come from useMemo and useCallback inside the hook, not from the extraction itself.

Where You'll See This in Real Code

React Query's useQuery hook is the most widely used custom hook in the ecosystem. Internally it composes useState for data/loading/error state, useEffect for fetching and cache management, useRef for tracking request identity, and useContext for accessing the query client. The entire power of React Query — caching, deduplication, background refetch — is delivered through a single useQuery custom hook call, hiding thousands of lines of infrastructure logic.

GitHub uses a useFocusTrap custom hook across all its modal dialogs. The hook registers keydown listeners to intercept Tab and Shift+Tab, finds all focusable elements within the modal container, and cycles focus among them. On cleanup it restores focus to the element that was active before the modal opened. Every modal in GitHub's UI gets this behaviour by adding one hook call — no copy-paste, no divergence.

Shopify's Polaris design system ships custom hooks alongside its components: useToggle, useDisableBodyScroll, useMeasure, useScrollLock. Application developers at Shopify use these hooks directly in custom UIs that don't use Polaris components. The hooks are the reusable unit of behaviour, completely independent of the component that originally needed them.

Airbnb's search page uses a useUrlState custom hook that synchronises filter state (price range, dates, amenities) with URL query parameters. State changes update the URL; URL changes update the state. Back/forward navigation works for free. Any component in the tree that calls useUrlState gets the current filter values — no prop drilling, no Context boilerplate.

Stripe's dashboard uses a useAsync custom hook that wraps any async function, tracks its pending/resolved/rejected state, and provides a stable execute function. Every API mutation (create payment, update subscription, issue refund) uses the same hook. The loading spinner, error toast, and success redirect logic are all driven by the three values the hook returns.

Interview Cheat Sheet

  • Custom hook = function starting with "use" that calls other hooks — nothing more
  • Shares logic (code), not state (values) — each component gets its own isolated state
  • "use" prefix enables linter rules — omit it and you lose rules-of-hooks and exhaustive deps protection
  • Return arrays for ≤2 values (like useState), return objects for 3+ or when names matter
  • Custom hooks can call other custom hooks — compose layers: primitive → infrastructure → domain
  • Extraction has no inherent performance benefit — useMemo/useCallback inside the hook provides perf
  • Always handle cleanup in hooks that subscribe, fetch, or set timers — same rules as useEffect
  • Test with renderHook from @testing-library/react, wrap mutations in act()
  • Extract when: same useEffect+state pattern appears in 2+ components, OR complexity warrants a name
  • Don't extract when: used in only one place AND the logic is simple
  • Never return JSX from a hook — that's a component
  • useRef inside hooks: use for mutable values that shouldn't trigger re-renders (latest callback, abort controller)
  • The "latest ref" pattern: store handler in ref, update it in useEffect — avoids stale closures without adding handler to deps
💡

How to Answer in an Interview

  • 1.The clearest definition: "A custom hook is a function that starts with 'use' and calls React hooks inside it. It extracts a behaviour — stateful logic that spans multiple renders — and gives it a name. It shares the code, not the state: two components using the same hook each get their own independent copy of all its state."
  • 2.When asked to write a custom hook live, always write useFetch or useDebounce — they are the canonical examples. Write the full implementation including cleanup and error handling. A useFetch without AbortController and error state is an incomplete answer. Show you know the full pattern.
  • 3.Connect custom hooks to the "use" prefix rule explicitly: "The prefix is not just convention — it tells the React linter to enforce Rules of Hooks on this function. Calling useState inside a function named fetchData silently bypasses linting and can produce ordering bugs that are very hard to trace."
  • 4.When asked about hook composition, give the layered architecture answer: "I think of hooks in three layers — primitive hooks like useState and useEffect at the bottom, infrastructure hooks like useFetch and useDebounce in the middle, and domain hooks like useUserProfile or useCartItem at the top. Each layer builds on the one below. The domain layer is what component code actually calls — a single hook call that hides all the infrastructure complexity."
  • 5.The testing answer is a strong signal: "I test custom hooks with renderHook from Testing Library — it creates a minimal host component for the hook to live in. State updates go inside act(). I test each returned value and function independently, and I test cleanup by unmounting the result and verifying subscriptions are removed." Most candidates don't know renderHook exists.

Practice Questions

No questions tagged to this topic yet.

Related Topics

useState Hook — Complete React Interview Guide
Beginner·4–8 Qs
useCallback Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useMemo Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useReducer Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useEffect Hook — Complete React Interview Guide
Beginner·4–8 Qs
useContext Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useRef Hook — Complete React Interview Guide
Beginner·4–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz