Memoization and useCallback

Functions are recreated on every render — here's why that matters and when useCallback actually helps.

March 22, 20264 min read1 / 5

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.

TSX
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.

TSX
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:

TSX
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.

TSX
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.

TSX
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.

TSX
// 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.

Stabilise a Callback with useCallback
1 exercise

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.