React useCallback Deep Dive
Table of Contents + −
In the last lesson, React useMemo Deep Dive, you saw how a fresh object each render breaks React.memo. Functions hit the same trap, so this lesson digs into useCallback, its dependency rules, and the stale-closure bug.
🤔 Why functions break memo
Here’s the core issue, the same identity problem as objects, but for functions.
- A function defined inside a component is recreated every render, a new function each time.
===on functions checks identity, so the new one is never equal to the old one.- So a memoized child receiving that function as a prop sees a “changed” prop and re-renders anyway.
- The function does the same thing, but its identity is different, which is all
memochecks.
So just like objects, a fresh function each render defeats React.memo. useCallback fixes the identity.
🔗 Seeing the problem
Let’s prove it. A memoized child takes a function prop, and the parent recreates that function every render.
import { useState, memo } from "react";
const Child = memo(function Child({ onClick }) { console.log("Child rendered"); return <button onClick={onClick}>Click</button>;});
function Parent() { const [count, setCount] = useState(0);
// ❌ a new function every render - breaks Child's memo const handleClick = () => console.log("clicked");
return ( <div> <button onClick={() => setCount(count + 1)}>Count: {count}</button> <Child onClick={handleClick} /> </div> );}Walk through why memo fails here.
- Each time
Parentre-renders,handleClickis a brand new function. Childis wrapped inmemo, but it sees a differentonClickevery render.- So
memothinks the props changed, andChildre-renders on every click. - The memo did nothing, because the function prop kept changing.
Output
Child rendered(click) Child rendered(click) Child rendered🧩 useCallback keeps the function stable
useCallback returns the same function across renders until a dependency changes. Wrap handleClick and memo finally works.
import { useState, useCallback, memo } from "react";
function Parent() { const [count, setCount] = useState(0);
// ✅ same function identity unless dependencies change const handleClick = useCallback(() => { console.log("clicked"); }, []);
return ( <div> <button onClick={() => setCount(count + 1)}>Count: {count}</button> <Child onClick={handleClick} /> </div> );}Here’s the result.
useCallback(fn, [])returns the same function on every render.Childnow sees the sameonClickeach time, somemoskips its render.- “Child rendered” logs once, not on every click.
- Change a dependency and
useCallbackgives a new function, soChildupdates correctly when it should.
So useCallback does for functions exactly what useMemo does for objects: keeps the identity stable so React.memo can do its job.
useCallback is useMemo for functions
Remember, useCallback(fn, deps) is the same as useMemo(() => fn, deps). One keeps a value stable, the other keeps a function stable. Same idea, same dependency rules.
🪤 The stale closure trap
Here’s the deep gotcha. A function “captures” the values around it when it’s created, so the wrong dependencies make it keep using old values.
const [count, setCount] = useState(0);
// ❌ empty deps - this function always sees count as 0 (its first value)const logCount = useCallback(() => { console.log(count); // stale: stuck on the count from when it was created}, []);
// ✅ list count - the function updates when count changesconst logCount = useCallback(() => { console.log(count);}, [count]);Understand what went wrong.
- With
[],useCallbackkeeps the very first version of the function forever. - That first version captured
countwhen it was0, so it always logs0. - Listing
countin the dependencies makesuseCallbackcreate a fresh function whencountchanges. - So the function always uses the current value. This is a stale closure: an old value frozen inside a memoized function.
Honest dependencies avoid stale closures
The stale closure is the most common useCallback bug. If your function reads count, count must be in the dependency array. Leaving it out to “keep the function stable” means the function quietly uses an old value. List everything it reads.
🧰 A tip: the functional updater avoids some deps
Sometimes you can drop a dependency by using the functional updater form of a setter, which doesn’t need to read the current state.
// instead of reading count (which would need [count])...const increment = useCallback(() => { setCount((prev) => prev + 1); // uses the latest value, no count dependency needed}, []);Quick note on this.
setCount((prev) => prev + 1)gets the latest value from React, so the function doesn’t readcountdirectly.- That means
countdoesn’t need to be a dependency, so the function can stay stable with[]. - This is a clean way to keep a callback stable without a stale closure.
So when a callback only updates state based on the old state, the functional updater lets you keep it stable safely.
⚠️ Common Mistakes
The top mistake is using useCallback when nothing compares the function, so it does nothing useful.
// ❌ pointless - Child is not memoized, so the stable function changes nothingconst handleClick = useCallback(() => doStuff(), []);return <RegularChild onClick={handleClick} />;
// ✅ useCallback pays off when the child is memoizedconst handleClick = useCallback(() => doStuff(), []);return <MemoizedChild onClick={handleClick} />;Keep these in mind.
- Don’t use
useCallbackunless the function is passed to amemochild or used in an effect dependency. - Don’t leave out dependencies the function reads, or you get a stale closure.
- Don’t forget:
useCallbackwithout amemochild usually changes nothing.
✅ Best Practices
A few habits for useCallback in performance work.
- Use it to keep a function prop stable for a
React.memochild, or stable for an effect dependency. - List every value the function reads in the dependency array, to avoid stale closures.
- Use the functional updater to drop a state dependency when you only update based on the old state.
- Skip it when nothing compares the function; a plain function is simpler.
It's always a pair
useCallback rarely helps alone. Its partner is React.memo on the child that receives the function. Use them together, with honest dependencies, and you actually stop the wasted renders.
🧩 What You’ve Learned
- ✅ A function defined in a component is new on every render, which breaks
React.memo - ✅
useCallback(fn, deps)keeps the same function identity until a dependency changes - ✅ A stable function prop lets a
React.memochild skip needless re-renders - ✅ A stale closure happens when missing dependencies freeze old values inside the function
- ✅ List every value the function reads; use the functional updater to drop a state dependency
- ✅
useCallbackmainly helps withmemochildren and effect dependencies, not alone
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why does a function defined in a component break React.memo on a child?
Why: A function written in the component is recreated every render, a new identity each time. memo compares with ===, so it always looks changed and the child re-renders.
- 2
What does useCallback return?
Why: useCallback keeps the function's identity stable, returning the same function across renders until something in its dependency array changes.
- 3
What is a stale closure?
Why: If you omit a value the function reads from the dependency array, useCallback freezes the old version, so the function keeps using stale values.
- 4
How can the functional updater help with useCallback dependencies?
Why: The functional updater gets the latest state from React, so the function doesn't read count directly. That lets the callback stay stable without a stale closure.
🚀 What’s Next?
Now you can keep both objects and functions stable so React.memo works. Let’s pull it all together. Next you’ll learn the practical patterns for avoiding unnecessary re-renders across a real component tree.