Learn exactly when useCallback prevents re-renders and when it's wasted overhead — the most misused hook in React.
Every time a React component renders, every function declared inside it is recreated as a brand new object in memory. Two renders, two different function objects — even if the code is identical. For most components, this is completely fine. The function is used once, the component re-renders, a new function is created, and the old one is garbage collected. No problem. The problem arises when that function is passed as a prop to a memoized child component, or listed as a dependency in a useEffect. In both cases, React compares the current function with the previous one using reference equality. Two different function objects — even with identical code — are never equal by reference. So the memoized child always re-renders, and the effect always re-runs. useCallback solves this by returning the same function reference across renders, only creating a new one when the dependencies change. It doesn't make the function itself faster — it makes the reference stable. The most important thing to know about useCallback: it is useless without React.memo on the child receiving the function, or without the function being in a useEffect dependency array. Those are the only two scenarios where function reference stability actually matters.
function Parent() {
const [count, setCount] = useState(0)
// New function object created on every render
const handleClick = () => console.log('clicked')
return <Child onClick={handleClick} />
}
// Even if Child is wrapped in React.memo:
const Child = React.memo(({ onClick }) => {
console.log('Child rendered')
return <button onClick={onClick}>Click</button>
})
// Child renders every time Parent renders
// because onClick is always a new function reference
function Parent() {
const [count, setCount] = useState(0)
// Same function reference until dependencies change
const handleClick = useCallback(() => {
console.log('clicked')
}, []) // no deps — never recreated
return (
<div>
<Child onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>{count}</button>
</div>
)
}
const Child = React.memo(({ onClick }) => {
console.log('Child rendered') // only logs on mount now
return <button onClick={onClick}>Click</button>
})
// Child no longer re-renders when count changes ✓
// Without useCallback — fetchData is new on every render → effect runs every render
function UserDashboard({ userId }) {
const fetchData = () => fetch('/api/users/' + userId) // new fn every render
useEffect(() => {
fetchData()
}, [fetchData]) // fetchData always "changes" → infinite loop!
}
// With useCallback — effect only re-runs when userId changes
function UserDashboard({ userId }) {
const fetchData = useCallback(() => {
return fetch('/api/users/' + userId)
}, [userId]) // new function only when userId changes
useEffect(() => {
fetchData()
}, [fetchData]) // ✓ only runs when userId changes
}
If a callback closes over state but doesn't list it in dependencies, the callback becomes stale:
function SearchBox() {
const [query, setQuery] = useState('')
// ✗ Stale — query is always '' inside handleSearch
const handleSearch = useCallback(() => {
searchAPI(query) // query is stale!
}, []) // ← missing query
// ✓ Add query to deps
const handleSearch = useCallback(() => {
searchAPI(query)
}, [query]) // re-created when query changes
// ✓ Alternative — useRef to break closure without deps
const queryRef = useRef(query)
useEffect(() => { queryRef.current = query }, [query])
const handleSearch = useCallback(() => {
searchAPI(queryRef.current) // always current
}, []) // stable reference
}
// ✗ Useless — Child is NOT React.memo'd
function Parent() {
const handleClick = useCallback(() => doSomething(), [])
return <Child onClick={handleClick} /> // not memoized
}
function Child({ onClick }) { // re-renders when Parent re-renders — always
return <button onClick={onClick}>Click</button>
}
// Child re-renders every time Parent does regardless of handleClick stability.
// useCallback added cost (comparison + cache) with zero benefit.
// ✗ Useless — function not in deps array
const fn = useCallback(() => doSomething(), [])
// fn is used as a click handler but not passed to memo'd component or useEffect
// Stable reference does nothing here
// Is the function passed to a React.memo child?
// YES → useCallback to prevent unnecessary re-renders
const memoChild = React.memo(Child)
const handler = useCallback(fn, deps)
<memoChild onAction={handler} />
// Is the function in a useEffect dependency array?
// YES → useCallback to prevent infinite loops
const fetchFn = useCallback(() => fetch(url), [url])
useEffect(() => { fetchFn() }, [fetchFn])
// Is the function passed to useCallback from a custom hook and consumers will put it in deps?
// YES → useCallback inside the hook
function useSearch(query) {
const search = useCallback(() => fetchResults(query), [query])
return { search }
}
// Everything else? → inline function is fine
<button onClick={() => setCount(c => c + 1)}>{count}</button>
A stable function reference that always calls the latest version of a callback — without listing dependencies:
// Problem: need stable reference BUT callback must always be current
// Solution: ref stores latest fn, stable wrapper calls it
function useStableCallback(fn) {
const fnRef = useRef(fn)
// Always update ref after render (not during — would cause issues)
useEffect(() => { fnRef.current = fn })
// Stable reference — never changes, always calls latest fn
return useCallback((...args) => fnRef.current(...args), [])
}
// Usage
function Component({ onSuccess }) {
const stableOnSuccess = useStableCallback(onSuccess)
// Effect never re-runs due to onSuccess changes — stableOnSuccess is always stable
useEffect(() => {
const sub = subscribe(event => stableOnSuccess(event))
return () => sub.unsubscribe()
}, [stableOnSuccess])
}
React 19 introduces useEffectEvent as the official version of this pattern.
useCallback allocates memory, runs comparison checks, and retains the previous function. It adds CPU and memory cost on every render. The net benefit is negative unless the cost of re-rendering the child component (or re-running the effect) is greater than the memoization overhead.
// Measuring the break-even:
// useCallback cost: ~0.5-2ms per render (comparison + cache)
// Prevented child re-render cost: depends on child complexity
// For a simple button: useCallback costs MORE than the re-render
// For an expensive chart component: useCallback saves 50ms+ per renderMany developers think useCallback makes the function itself run faster — but it does nothing to the function's execution speed. It only stabilizes the reference. The function body runs at exactly the same speed with or without useCallback.
Many developers think useCallback alone prevents child re-renders — but useCallback is only half the equation. The child component must also be wrapped in React.memo. Without React.memo, the child re-renders whenever the parent re-renders, regardless of whether the prop function reference changed.
Many developers think adding useCallback to all functions is a performance best practice — but this is premature optimization and often makes things worse. useCallback adds comparison overhead on every render. For components with simple children or functions not in deps arrays, the cost exceeds the benefit.
Many developers think useCallback with an empty dependency array creates a function that is always the same — it does create a stable reference, but the function body closes over the values from the first render only. If the function uses state or props without listing them as deps, it has a stale closure. The stable reference and stale values can create subtle bugs.
Many developers think useCallback and useMemo are interchangeable for functions — useCallback(fn, deps) is literally sugar for useMemo(() => fn, deps). The only difference is intent: useCallback signals "I'm memoizing a function", useMemo signals "I'm memoizing a computed value".
Many developers think inline arrow functions in JSX always cause performance issues — but for non-memoized children (which is most children), new function references make absolutely no difference. The child re-renders when the parent re-renders regardless. The function reference is irrelevant.
Design system component libraries (like Radix UI and MUI) internally wrap all callback props in useCallback to prevent consumers' usage from accidentally invalidating memoization. When a Button component receives an onClick, the library ensures the internal event handler has a stable reference — consumers shouldn't need to think about this.
Virtual list implementations (react-window, react-virtual) require stable row renderer functions. Each row is a memoized component — if the renderRow function changes on every parent render, all thousand rows re-render on every scroll event. useCallback on the row renderer is non-negotiable for these use cases.
Real-time dashboards with frequent state updates use useCallback to prevent non-data components (navigation, filters, headers) from re-rendering when the live data updates. The filter handlers are wrapped in useCallback so the filter bar doesn't re-render when new data points arrive at 10 times per second.
Custom hooks that return functions always wrap them in useCallback so consumers can safely put them in useEffect dependency arrays. If useFetch returns a refetch function without useCallback, every consumer's useEffect would run on every render — this is why all well-written custom hooks return memoized functions.
Event delegation patterns in large lists (product catalogs, data tables) use useCallback for the event handlers to prevent list item re-renders. When a user opens a dropdown, only the state for that dropdown changes — all other items should skip re-rendering, which requires their onClick handlers to be stable.
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.