Skip to content

React · Hooks

React Custom Hooks — Complete Interview Guide with Examples

10 min read Updated 2026-06-23 Share:

Practice Custom Hooks interview questions

Why React custom hooks matter in interviews

React custom hooks are one of the first things senior interviewers ask about because they reveal how well you understand the hooks model at a conceptual level — not just the built-in hooks, but why they exist and how to compose them into reusable patterns.

The question usually starts simple ("What is a custom hook?") and escalates quickly: "Write a useDebounce hook." "How would you test it?" "Why is naming the function useX important?" "How does it differ from a higher-order component?" These questions all probe whether you've internalized hooks as a composition primitive, not just an API to memorize.

What a custom hook actually is

A custom hook is a plain JavaScript function whose name starts with use and that calls at least one React hook internally. Nothing more. No special API, no class, no registration — just a function that follows the rules of hooks.

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth)

  useEffect(() => {
    const handler = () => setWidth(window.innerWidth)
    window.addEventListener('resize', handler)
    return () => window.removeEventListener('resize', handler)
  }, [])

  return width
}

// any component can use it
const width = useWindowWidth()

Each component that calls useWindowWidth gets its own isolated state. Custom hooks are not singletons — calling the same hook in two components gives each its own width and handler.

Why the use prefix is required

The use prefix is how React and the eslint-plugin-react-hooks linter identify hook functions. Without it, the linter won't apply the rules of hooks (called at top level, not in conditions) to your function's internals — and violations inside it go unchecked.

// linter treats this as a plain function — hook violations inside are invisible
function getTheme() {
  const [theme] = useState('dark') // no lint warning for calling inside a condition
  return theme
}

// linter applies rules of hooks — violations are caught
function useTheme() {
  const [theme] = useState('dark')
  return theme
}

The prefix is also a convention that signals to other developers: "this function uses hooks and must be called at the top level of a component or another hook."

Custom hook vs. utility function

If a function doesn't call any hooks, it's a utility function — it can be called anywhere, including outside React. A custom hook requires hooks, which means it must follow the rules of hooks.

// utility: no hooks, usable anywhere
function formatCurrency(amount, locale) {
  return new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(amount)
}

// custom hook: has a hook, must be called at top level of a component
function useCurrency(amount) {
  const { locale } = useContext(LocaleContext) // uses a hook
  return formatCurrency(amount, locale)
}

Extract to a custom hook only when you genuinely need React hooks. Otherwise, a plain utility function is cleaner and more portable.

Custom hooks vs. HOCs and render props

All three patterns share stateful logic across components. Custom hooks win on simplicity:

HOCRender propCustom hook
Extra component in treeYesYesNo
Props renaming conflictsYesNoNo
TypeScript inferenceHardOKExcellent
JSX changes neededYesYesNo
// HOC — wraps the component
const EnhancedPage = withWindowWidth(Page)

// render prop — restructures JSX
<WindowWidth>{width => <Page width={width} />}</WindowWidth>

// custom hook — plain function call
function Page() {
  const width = useWindowWidth()
  return <div>{width}</div>
}

Custom hooks are the primary reason HOCs and render props have largely faded from modern React codebases.

Essential patterns every interviewer expects

usePrevious

Track the value from the previous render using a ref that updates after each render.

function usePrevious(value) {
  const ref = useRef()
  useEffect(() => { ref.current = value }) // runs after render
  return ref.current                        // returns last render's value
}

const prevCount = usePrevious(count)
// during render: ref.current is still the value from the previous render
// after render: useEffect fires and syncs it to the current value

useDebounce

Delay updating a "debounced" value until input settles for a given time.

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id) // cancel on next change
  }, [value, delay])

  return debounced
}

// usage
const debouncedQuery = useDebounce(query, 400)
// run search on debouncedQuery, not query

The cleanup cancels the pending timeout on each change, so setDebounced only fires after the user pauses for delay milliseconds.

useLocalStorage

Persist state to localStorage with the same interface as useState.

function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key)
      return stored !== null ? JSON.parse(stored) : initial
    } catch {
      return initial
    }
  })

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])

  return [value, setValue]
}

const [theme, setTheme] = useLocalStorage('theme', 'dark')

The lazy initializer reads storage once on mount; the effect persists every change. The try/catch handles SSR environments where localStorage doesn't exist.

useMediaQuery

Subscribe to a CSS media query and return whether it currently matches.

function useMediaQuery(query) {
  const [matches, setMatches] = useState(
    () => typeof window !== 'undefined' && window.matchMedia(query).matches
  )

  useEffect(() => {
    const mq = window.matchMedia(query)
    const handler = e => setMatches(e.matches)
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [query])

  return matches
}

const isDesktop = useMediaQuery('(min-width: 1024px)')

The typeof window !== 'undefined' guard prevents SSR crashes.

useDataFetch

Encapsulate the loading/error/data state pattern that appears in every component that fetches data.

function useDataFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null })

  useEffect(() => {
    let active = true
    setState({ data: null, loading: true, error: null })
    fetch(url)
      .then(r => r.json())
      .then(data  => { if (active) setState({ data, loading: false, error: null }) })
      .catch(error => { if (active) setState({ data: null, loading: false, error }) })
    return () => { active = false }
  }, [url])

  return state
}

const { data, loading, error } = useDataFetch(`/api/user/${id}`)

In production, prefer React Query or SWR — but writing this from scratch in an interview shows you understand the race-condition problem and its solution.

Hook composition

Custom hooks can call other custom hooks, enabling a layered architecture that mirrors how components compose.

function useUserPreferences() {
  const [theme, setTheme] = useLocalStorage('theme', 'dark') // custom hook
  const isDesktop = useMediaQuery('(min-width: 1024px)')      // custom hook
  const { locale } = useContext(LocaleContext)
  return { theme, setTheme, isDesktop, locale }
}

Each layer stays focused and individually testable. The composed hook just orchestrates them rather than re-implementing each concern.

When to extract a custom hook

Three good signals:

  1. The same hook combination appears in two or more components. Three setters for loading/error/data is a useFetch hook waiting to be written.
  2. A component's hook logic obscures what it renders. If you have to scroll past 20 lines of hooks to find the JSX, the hooks belong somewhere else.
  3. You want to test the logic independently. A custom hook can be tested with renderHook without rendering the full component.

Don't extract proactively. A hook with a single caller adds indirection without benefit.

What can a custom hook return?

Anything — a single value, a tuple, an object, or nothing.

  • Tuple ([value, setter]) — when callers will rename the parts, like useState.
  • Object ({ data, loading, error }) — when there are several named exports and destructuring by name is clearer.
  • Single value — when there's only one thing to return.
// tuple: rename freely at the call site
const [open, setOpen] = useToggle(false)

// object: pick what you need
const { user, isLoading } = useCurrentUser()

Choose based on readability at the call site, not based on convention.

Testing custom hooks

Use @testing-library/react's renderHook to mount a minimal host component and assert on the hook's return value. Wrap state updates in act so React flushes them before assertions.

import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

test('increments count', () => {
  const { result } = renderHook(() => useCounter(0))
  expect(result.current.count).toBe(0)
  act(() => result.current.increment())
  expect(result.current.count).toBe(1)
})

test('resets to initial', () => {
  const { result } = renderHook(() => useCounter(5))
  act(() => result.current.reset())
  expect(result.current.count).toBe(5)
})

For hooks with context dependencies, wrap the renderHook call in the Provider:

renderHook(() => useAuth(), { wrapper: AuthProvider })

Stale closure bugs in custom hooks

A custom hook can suffer stale closure bugs just like inline hook calls. If a callback inside the hook reads a prop or state but isn't in the dependency array, it captures a frozen snapshot.

// broken: callback captured once, never updates
function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay) // stale: callback changes, interval doesn't
    return () => clearInterval(id)
  }, [delay])
}

Fix with the ref-mirror pattern:

function useInterval(callback, delay) {
  const cbRef = useRef(callback)
  useEffect(() => { cbRef.current = callback }) // always current

  useEffect(() => {
    const id = setInterval(() => cbRef.current(), delay)
    return () => clearInterval(id)
  }, [delay])
}

Common interview questions at a glance

  • What is a custom hook? A function whose name starts with use that calls one or more React hooks — the primary pattern for sharing stateful logic.
  • Why must it start with use? The linter uses the prefix to identify hooks and apply the rules of hooks to the function's internals.
  • How does it differ from a utility function? A utility function has no hooks and can be called anywhere; a custom hook has hooks and must follow the rules of hooks.
  • Does each caller get its own state? Yes — each component that calls a custom hook gets its own isolated state; hooks are not singletons.
  • How do you test a custom hook? Use renderHook from @testing-library/react and wrap updates in act.
  • When should you create a custom hook? When the same hook combination appears in two+ places, or when hook logic obscures the component's render output.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel