Memoization and useCallback
Functions are recreated on every render — here's why that matters and when useCallback actually helps.
Pushing state down is the first and most effective React performance technique. But there's a wall you hit after the refactor: even when state lives exactly where it should, components that receive function props still re-render more than they need to.
Memoization is the answer — and useCallback is the first tool in that kit.
What Memoization Means
Memoization is caching. Given a set of inputs, store the output the first time and return the cached result on subsequent calls if the inputs haven't changed.
In React, the "inputs" are props or dependencies. The "output" is a function reference, a computed value, or a rendered component.
The key insight: JavaScript functions are objects. Every time a component renders, every function defined in that component body is a new object in memory — a different reference, even if the logic is identical.
function Counter() {
const [count, setCount] = useState(0);
// A new function reference is created on every single render
const handleIncrement = () => setCount(c => c + 1);
return <Button onClick={handleIncrement} />;
}Call Counter twice. You get two different handleIncrement functions. They do the same thing — but === returns false between them.
This normally doesn't matter. But it matters the moment you combine it with React.memo.
The Re-render Cycle
React.memo wraps a component and skips its re-render if props haven't changed — using shallow equality comparison.
const Button = memo(function Button({ onClick, label }) {
// Only re-renders when onClick or label actually change
return <button onClick={onClick}>{label}</button>;
});"Shallow equality" means it compares prop values with ===. For primitive props (strings, numbers, booleans), this works perfectly — "increment" === "increment" is always true.
For function props, it's a different story:
function Counter() {
const [count, setCount] = useState(0);
// New function every render ↓
const handleIncrement = () => setCount(c => c + 1);
return (
<>
<p>{count}</p>
{/* Even though Button is memo'd, it re-renders every time — */}
{/* because handleIncrement is a new reference on every render */}
<Button onClick={handleIncrement} label="Increment" />
</>
);
}Button is wrapped in memo, but it re-renders every time Counter re-renders. The onClick prop is technically "different" on every render even though the function does the same thing.
useCallback: Stable Function References
useCallback takes a function and a dependency array and returns the same function reference between renders — until the dependencies change.
function Counter() {
const [count, setCount] = useState(0);
// Same function reference as long as setCount doesn't change
const handleIncrement = useCallback(
() => setCount(c => c + 1),
[] // setCount is stable — safe to omit (React guarantees it)
);
return (
<>
<p>{count}</p>
{/* Now Button only re-renders when handleIncrement reference changes */}
<Button onClick={handleIncrement} label="Increment" />
</>
);
}handleIncrement is now the same function object across renders. When Counter re-renders, Button receives the same onClick reference → memo's shallow comparison passes → Button skips its re-render.
The Dependency Array
The dependency array tells React when to create a new function. If the function body closes over a value, that value belongs in the array.
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('');
// ❌ Stale closure — query is captured at creation time
const handleSearch = useCallback(() => {
onSearch(query);
}, []); // Missing query and onSearch
// ✅ Correct — recreates when query or onSearch changes
const handleSearch = useCallback(() => {
onSearch(query);
}, [query, onSearch]);
}A stale closure is a subtle bug: the function reference stays the same, but the values it closes over are out of date. ESLint's exhaustive-deps rule catches these.
When useCallback Actually Helps
useCallback is only useful in two situations:
1. Passing a function to a React.memo'd child
If the child isn't memoized, useCallback does nothing useful — the child re-renders anyway.
2. As a dependency of another hook
If a function is listed in a useEffect or another useCallback's dependency array, stabilizing its reference prevents the effect from re-running unnecessarily.
// Without useCallback, this effect runs after every render
// because fetchData is a new function reference every time
useEffect(() => {
fetchData();
}, [fetchData]);When Not to Use It
The overhead of useCallback is small but real — React still runs the hook every render to check the dependency array. For every other case (a handler used only in the current component, not passed as a prop to a memoized child), it's a cost with no benefit.
A common mistake is sprinkling useCallback everywhere "for performance." It adds cognitive overhead, makes the code harder to read, and can actually slow things down slightly by adding work on every render.
The rule: reach for useCallback when you have a concrete reason — a memoized child that needs a stable reference, or a dependency of another hook.
What's Next
useCallback stabilizes function references, but it only helps when the receiving component is wrapped in React.memo. In the next post, I'll look at how React.memo works under the hood — and why referential equality with objects requires a different tool.
Practice what you just read.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.