React useCallback Deep Dive

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 memo checks.

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 Parent re-renders, handleClick is a brand new function.
  • Child is wrapped in memo, but it sees a different onClick every render.
  • So memo thinks the props changed, and Child re-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.
  • Child now sees the same onClick each time, so memo skips its render.
  • “Child rendered” logs once, not on every click.
  • Change a dependency and useCallback gives a new function, so Child updates 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 changes
const logCount = useCallback(() => {
console.log(count);
}, [count]);

Understand what went wrong.

  • With [], useCallback keeps the very first version of the function forever.
  • That first version captured count when it was 0, so it always logs 0.
  • Listing count in the dependencies makes useCallback create a fresh function when count changes.
  • 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 read count directly.
  • That means count doesn’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 nothing
const handleClick = useCallback(() => doStuff(), []);
return <RegularChild onClick={handleClick} />;
// ✅ useCallback pays off when the child is memoized
const handleClick = useCallback(() => doStuff(), []);
return <MemoizedChild onClick={handleClick} />;

Keep these in mind.

  • Don’t use useCallback unless the function is passed to a memo child or used in an effect dependency.
  • Don’t leave out dependencies the function reads, or you get a stale closure.
  • Don’t forget: useCallback without a memo child usually changes nothing.

✅ Best Practices

A few habits for useCallback in performance work.

  • Use it to keep a function prop stable for a React.memo child, 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.memo child 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
  • useCallback mainly helps with memo children and effect dependencies, not alone

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

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

React Avoiding Unnecessary Re-renders

Share & Connect