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:
| HOC | Render prop | Custom hook | |
|---|---|---|---|
| Extra component in tree | Yes | Yes | No |
| Props renaming conflicts | Yes | No | No |
| TypeScript inference | Hard | OK | Excellent |
| JSX changes needed | Yes | Yes | No |
// 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:
- The same hook combination appears in two or more components. Three setters for
loading/error/data is a
useFetchhook waiting to be written. - 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.
- You want to test the logic independently. A custom hook can be tested with
renderHookwithout 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, likeuseState. - 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
usethat 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
renderHookfrom@testing-library/reactand wrap updates inact. - 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.