A custom hook is a plain JavaScript function whose name starts with use and
that calls one or more React hooks internally. It's the primary way to extract and
share stateful logic between components without changing their structure.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return width
}
function Banner() {
const width = useWindowWidth() // any component can share this logic
return <div>{width > 768 ? 'desktop' : 'mobile'}</div>
}
Each component that calls a custom hook gets its own isolated state — hooks
are not singletons. Calling useWindowWidth in two components gives each its own
width and handleResize.
React's rules-of-hooks linter (eslint-plugin-react-hooks) and React itself use
the use prefix as the signal that a function is a hook and must follow the
rules of hooks (called at the top level, not in conditions, etc.). Without use,
the lint rule won't check the function's internals for hook violations.
// not recognized as a hook — lint won't guard it
function getUser() {
const [user] = useState(null) // lint rule misses this
return user
}
// recognized, fully linted
function useUser() {
const [user] = useState(null)
return user
}
The use prefix is also a convention that communicates to other developers:
"this function uses hooks and must follow the rules."
A utility function is a plain function with no hooks — it can be called anywhere (inside or outside React). A custom hook calls at least one built-in or custom React hook, so it must follow the rules of hooks.
// utility: no hooks, usable anywhere
function formatDate(date) {
return new Intl.DateTimeFormat('en-US').format(date)
}
// custom hook: has state, must be called at the top level of a component
function useFormattedDate(date) {
const { locale } = useContext(LocaleContext) // uses a hook
return new Intl.DateTimeFormat(locale).format(date)
}
If you don't need React hooks, write a plain utility. Extract to a custom hook only when the logic requires state, effects, context, or other hook functionality.
All three share stateful logic across components, but hooks are simpler:
- HOC wraps a component, adds indirection, and can cause "wrapper hell" in DevTools.
- Render props avoid the wrapper issue but require restructuring JSX with a callback function.
- Custom hook is a plain function call — no extra component, no JSX change, full TypeScript inference.
// HOC
const EnhancedComponent = withWindowWidth(MyComponent)
// render prop
<WindowWidth>{width => <MyComponent width={width} />}</WindowWidth>
// custom hook — simplest
const width = useWindowWidth()
Custom hooks are the idiomatic modern alternative and the reason HOCs/render props have faded in React codebases.
Store the previous value in a ref, syncing it to the latest value after each render via an effect.
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value // run after render, so ref still holds old value
})
return ref.current
}
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
return <p>now: {count}, before: {prevCount}</p>
}
The key insight: useEffect (no deps) runs after every render. So during the
current render, ref.current still has the value from the previous render —
exactly what the caller wants. The update in the effect fires after the return.
Maintain a debounced value in state; update it via a timeout that gets reset whenever the raw value changes.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id) // reset on new value
}, [value, delay])
return debounced
}
function Search() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 400)
// run API call on debouncedQuery, not query
}
The cleanup function cancels the pending timeout on every keystroke, so
setDebounced only fires after the user pauses for delay ms.
Initialize state lazily from localStorage, then sync every update back to localStorage in an effect.
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 try/catch handles JSON parse errors or SSR environments where
localStorage doesn't exist. The lazy initializer reads storage once on mount.
Encapsulate the loading/error/data state and the race-condition guard inside a hook that any component can call with a URL.
function useFetch(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(err => { if (active) setState({ data: null, loading: false, error: err }) })
return () => { active = false }
}, [url])
return state
}
In production, prefer a data library (React Query, SWR) that handles caching, deduplication, and retries — but this pattern shows you know the pitfalls.
Use window.matchMedia to evaluate the query and subscribe to changes.
function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => 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)')
Guard the initial state for SSR (where window doesn't exist) by checking
typeof window !== 'undefined' or defaulting to false.
Yes — and this is encouraged. Composing simple custom hooks into more complex ones mirrors how components compose, following the same mental model.
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 testable. The top-level hook just orchestrates the pieces rather than reimplementing each concern.
Extract when: (1) the same hook combination appears in two or more components, (2) a component's hook logic is complex enough to obscure what the component renders, or (3) you want to test the logic independently of the rendering.
// if this pattern appears in three components, extract it
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// -> extract to useFetch(url)
Don't extract proactively. A hook with a single caller adds indirection without benefit. Wait until duplication or complexity makes the extraction clearly worth the added abstraction.
Use @testing-library/react's renderHook utility, which mounts a minimal
component that calls your hook and returns its result.
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)
})
act wraps state updates so the hook's state flushes before your assertions.
Wrap effects or async updates in act with await if they're async.
Anything — a single value, a tuple (like useState), an object, or nothing. The
convention is:
- Tuple when the hook is analogous to
useStateand the caller will rename the parts:const [value, setValue] = useLocalStorage(key, init). - Object when there are many named exports and destructuring by name is
clearer:
const { data, loading, error } = useFetch(url). - Single value when there's only one thing to return:
const width = useWindowWidth().
// tuple: callers rename freely
const [open, setOpen] = useToggle(false)
// object: callers pick what they need
const { user, logout } = useAuth()
Choose based on readability at the call site, not based on what's "correct."
A custom hook captures props or parent state in closures just like inline hook calls do. If a callback inside the hook isn't in the dependency array, it closes over a stale value.
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay) // stale: callback changes but interval isn't reset
return () => clearInterval(id)
}, [delay]) // missing `callback`
}
The fix: include callback in the dep array (causes interval teardown/recreate
on every render if callback isn't stable) or use the ref pattern — mirror the
callback into a ref each render and call ref.current inside the interval.
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])
}
More Hooks interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.