Learn React custom hooks with real-world examples like useFetch and useForm. Write reusable, clean, and scalable React code.
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.
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.
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.
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}
}
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>
)
}
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')
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>
}
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>
)
}
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>
}
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]
}
// 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>
)
}
// 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
// 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-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 infrastructureMany 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.
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.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.