Every time a component re-renders, any function defined directly inside it, including inline event handlers, is recreated as a brand-new function value, even if its logic is identical to the previous render's version — this normally doesn't matter, but it does specifically when that function is passed as a prop to a child wrapped in React.memo(), since a new function reference on every render defeats that memoization, causing the child to re-render anyway despite React.memo()'s comparison. useCallback(fn, deps) returns the exact same function reference across renders as long as the dependency array's values haven't changed, preserving that memoized child's optimization.
1Understanding useCallback
Every time a component re-renders, any function defined directly inside it, including inline event handlers, is recreated as a brand-new function value, even if its logic is identical to the previous render's version — this normally doesn't matter, but it does specifically when that function is passed as a prop to a child wrapped in React.memo(), since a new function reference on every render defeats that memoization, causing the child to re-render anyway despite React.memo()'s comparison. useCallback(fn, deps) returns the exact same function reference across renders as long as the dependency array's values haven't changed, preserving that memoized child's optimization.
useCallback only matters when a function's reference identity actually matters somewhere downstream, most commonly as a prop to a React.memo()-wrapped child, or as a dependency in another hook's dependency array — wrapping every function in a component with useCallback by default adds overhead without any real benefit in most other cases.
import { useCallback, useState } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('Clicked!');
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>Increment: {count}</button>
<MemoizedChild onClick={handleClick} />
</>
);
}2Practical Example
Here is a real-world application of useCallback showing how it is used in production React code.
const MemoizedChild = React.memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Child Button</button>;
});3Best Practices
Follow these guidelines when working with useCallback:
1. Use useCallback specifically for functions passed as props to a React.memo()-wrapped child, so a new reference on every parent render doesn't defeat that child's memoization
2. Use useCallback for a function that's itself listed as a dependency in another hook, like useEffect, to avoid that effect re-running on every render due to a constantly-new function reference
3. Avoid wrapping every function in a component with useCallback by default, since the memoization bookkeeping itself has a cost that isn't worth paying when nothing downstream actually relies on a stable reference
Tip: useCallback only matters when a function's reference identity actually matters somewhere downstream, most commonly as a prop to a React.memo()-wrapped child, or as a dependency in another hook's dependency array — wrapping every function in a component with useCallback by default adds overhead without any real benefit in most other cases.
import { useCallback, useState } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('Clicked!');
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>Increment: {count}</button>
<MemoizedChild onClick={handleClick} />
</>
);
}4Stale Closures Inside useCallback
A memoized function is only recreated when its dependency array changes, so any value it reads from component scope but doesn't list as a dependency stays frozen at whatever it was when the current memoized version was created. An empty dependency array permanently traps the values from the very first render — the same stale-closure trap that affects useEffect.
An empty dependency array on useCallback doesn't mean 'always up to date' — it means 'frozen at what this closure saw on the render that created it.'
// ❌ Traps count's initial value (0) forever
const logCount = useCallback(() => {
console.log(count);
}, []);
// ✅ Reads the current count every time it's recreated
const logCount = useCallback(() => {
console.log(count);
}, [count]);5Feeding a Stable Callback Into useEffect
When a function is used both inside an effect and elsewhere (like an event handler), wrapping it in useCallback lets you safely list it as an effect dependency without the effect re-running on every render — since the function's reference only changes when its own dependencies actually change.
const fetchData = useCallback(() => {
api.get(`/items/${id}`).then(setItems);
}, [id]);
useEffect(() => {
fetchData();
}, [fetchData]);