🔗 The useCallback Hook
In the last lesson you saw that a fresh object or array prop quietly defeats React.memo. Functions have exactly the same problem — a component re-creates every function it defines on every render, so each is a new value. useCallback is the hook that pins a function's identity down, so the memoized children you hand it to can finally stay put.
Week 5 · Day 4 (Thursday: Performance Optimization) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why functions get a new identity on every render (the "function identity" problem)
- Use
useCallbackto memoize a function reference across renders - Write correct dependency arrays and describe the stale-closure bug that bad deps cause
- Distinguish
useCallback(fn, deps)fromuseMemo(() => fn, deps) - Combine
useCallbackwithReact.memoto keep list items and context consumers stable - Recognize over-use of
useCallbackand decide when it earns its cost
Estimated Time: 65 minutes
Practice: Fix a list whose rows all re-render on every click, and stabilize a context value.
In This Lesson
The Function Identity Problem
Imagine a film director giving actors the same note before every take: "same as we rehearsed." If the director instead re-explained the entire scene from scratch each take, the crew would treat it as brand-new direction and reset everything. That's what happens with functions in React — every render writes a brand-new function, and any component receiving it treats it as a new instruction.
Here's the concrete problem. This child is wrapped in React.memo, yet it re-renders on every parent render:
import { memo, useState } from 'react';
const ExpensiveChild = memo(function ExpensiveChild({ onClick }) {
console.log('ExpensiveChild rendered'); // logs on EVERY parent render 😞
return <button onClick={onClick}>Click me</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// A brand-new function object is created on every render of Parent
const handleClick = () => {
console.log('clicked!');
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* handleClick is "new" each render → memo sees changed props → re-render */}
<ExpensiveChild onClick={handleClick} />
</div>
);
}
Even though handleClick does the exact same thing every time, it's a different function value on each render. React.memo shallow-compares props with Object.is, decides prevOnClick !== nextOnClick, and re-renders. The memo is working perfectly — you're just feeding it unstable props.
each render] C -->|Yes, deps unchanged| E[Same function
reference reused] D --> F[memo child: props changed] E --> G[memo child: props equal] F --> H[Child RE-RENDERS] G --> I[Child SKIPS re-render]
useCallback Basics
useCallback memoizes a function. You give it a function and a dependency array; it returns the same function reference on every render until one of the dependencies changes. That's the entire idea.
import { memo, useCallback, useState } from 'react';
function Parent() {
const [count, setCount] = useState(0);
// Same reference across renders because deps never change ([])
const handleClick = useCallback(() => {
console.log('clicked!');
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Now ExpensiveChild receives a stable prop and skips re-renders */}
<ExpensiveChild onClick={handleClick} />
</div>
);
}
With the empty dependency array, handleClick is created once and reused forever. The memoized child now logs a single time no matter how often you click the counter, because its onClick prop is referentially stable.
📖 The two-part contract, again
useCallback only helps if the receiving component cares about referential equality — i.e. it's wrapped in React.memo, or the function is a dependency of another hook. Memoizing a callback and then passing it to a plain, non-memoized child accomplishes nothing: that child re-renders with its parent regardless.
Dependencies & Stale Closures
The dependency array works exactly like useMemo's: list every value from the surrounding scope that the function reads. Get it wrong and you don't get a crash — you get a stale closure, where the function keeps using an old value it captured on a previous render.
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// ✅ Recreated only when `query` changes — always searches the current query
const handleSearch = useCallback(() => {
fetchResults(query).then(setResults);
}, [query]);
// ✅ Reads no external values except the stable setters → never needs recreating
const handleClear = useCallback(() => {
setQuery('');
setResults([]);
}, []);
return (
<div>
<SearchInput value={query} onChange={setQuery}
onSearch={handleSearch} onClear={handleClear} />
<SearchResults results={results} />
</div>
);
}
⚠️ The stale-closure bug
// 🔴 WRONG: userId is read but missing from deps
function Profile({ userId }) {
const [data, setData] = useState(null);
const fetchUser = useCallback(async () => {
const res = await api.getUser(userId); // captures the FIRST userId forever
setData(res);
}, []); // should be [userId]
useEffect(() => { fetchUser(); }, [fetchUser]);
// When userId changes, fetchUser still fetches the OLD user. 🐞
}
Because userId was left out, fetchUser is never recreated, so it forever closes over the first userId. Add userId to the array and the function updates when it should. The exhaustive-deps ESLint rule catches this automatically.
✅ Tip: pass values as arguments to avoid dependencies
// No dependency needed — the value comes in as a parameter
const fetchUser = useCallback(async (id) => {
const res = await api.getUser(id);
setData(res);
}, []);
useEffect(() => { fetchUser(userId); }, [fetchUser, userId]);
When a function can receive what it needs as an argument, it stays dependency-free and maximally stable. Similarly, the functional setter form (setCount(c => c + 1)) lets a callback update state without depending on the current state value.
useCallback vs useMemo
These two hooks are close cousins. The difference is small but worth nailing down: useMemo caches the result of calling a function; useCallback caches the function itself.
useMemo | useCallback | |
|---|---|---|
| Caches | the value a function returns | the function reference |
| Runs the function? | Yes — during render, to get the value | No — just stores it for later calls |
| Typical use | expensive calculation, stable object/array | stable callback for a memo child or hook dep |
| Equivalent | useMemo(() => x, d) | useCallback(fn, d) === useMemo(() => fn, d) |
In fact useCallback(fn, deps) is exactly shorthand for useMemo(() => fn, deps). React added useCallback because memoizing functions is so common that the shorthand is worth having.
// These two lines are equivalent:
const memoFn = useCallback((id) => doThing(id), [dep]);
const memoFn = useMemo(() => (id) => doThing(id), [dep]);
Real-World Patterns
1. Optimizing list rendering
The classic payoff: a list where each row is a memoized component. Define the handler once in the parent with useCallback, and clicking or selecting one row won't re-render the other hundred.
function OptimizedList({ items }) {
const [selectedId, setSelectedId] = useState(null);
// One stable handler shared by every row
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
return (
<div>
{items.map(item => (
<ListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
onSelect={handleSelect}
/>
))}
</div>
);
}
const ListItem = memo(function ListItem({ item, isSelected, onSelect }) {
console.log(`Rendering ${item.id}`); // now only the changed rows log
return (
<div
className={`list-item ${isSelected ? 'selected' : ''}`}
onClick={() => onSelect(item.id)}
>
{item.title}
</div>
);
});
Because onSelect is stable and item is stable, the only prop that changes for most rows is nothing at all — memo skips them. Only the row whose isSelected flips actually re-renders.
2. Stabilizing a context value's methods
Context is a sneaky performance sink: every consumer re-renders when the provider's value changes reference. Memoize the methods with useCallback and the whole value object with useMemo, and consumers only update when real data changes.
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = useCallback(async (credentials) => {
const data = await api.login(credentials);
setUser(data);
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
// Stable value object: new only when user / login / logout change
const value = useMemo(
() => ({ user, login, logout }),
[user, login, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
3. Callbacks that interact with a ref
Media controls, focus management, and similar imperative code pair naturally with useCallback — the callbacks touch a ref, not reactive state, so an empty dependency array is correct and they stay perfectly stable.
function VideoPlayer({ src }) {
const videoRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
const play = useCallback(() => {
videoRef.current?.play();
setIsPlaying(true);
}, []);
const pause = useCallback(() => {
videoRef.current?.pause();
setIsPlaying(false);
}, []);
// Depends on play/pause (both stable) and isPlaying
const togglePlay = useCallback(() => {
isPlaying ? pause() : play();
}, [isPlaying, play, pause]);
return (
<div>
<video ref={videoRef} src={src} />
<VideoControls isPlaying={isPlaying} onPlayPause={togglePlay} />
</div>
);
}
When NOT to Use It
useCallback is not free. It runs on every render, allocates the dependency array, and adds visual noise. If the memoized function isn't feeding a React.memo child or a hook dependency, you're paying the cost for nothing.
// 🔴 Over-optimized: these callbacks go straight onto plain DOM buttons.
// Nothing downstream cares about their identity, so useCallback is pure overhead.
function Counter() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount(c => c + 1), []);
const reset = useCallback(() => setCount(0), []);
return (
<div>
<button onClick={increment}>+</button>
<button onClick={reset}>Reset</button>
<span>{count}</span>
</div>
);
}
// ✅ Just write the inline handlers — clearer and no cost:
function CounterSimple() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(0)}>Reset</button>
<span>{count}</span>
</div>
);
}
💡 The one-line rule
Reach for useCallback when the function is (1) passed to a component wrapped in React.memo, or (2) listed in another hook's dependency array. Otherwise, an inline function is simpler and just as fast.
Practice & Quiz
🏋️ Exercise 1: Stop every row from re-rendering
Goal: Each Row is memoized, yet clicking one logs a render for all rows. Fix the parent so only the affected row re-renders.
const Row = memo(function Row({ item, onPick }) {
console.log('Row', item.id, 'rendered');
return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});
function List({ items }) {
const [picked, setPicked] = useState(null);
const onPick = (id) => setPicked(id); // 🐞 new function every render
return (
<ul>
{items.map(it => <Row key={it.id} item={it} onPick={onPick} />)}
</ul>
);
}
💡 Hint
onPick is recreated on every render, so all memoized rows see a "new" prop. Memoize it with useCallback and an empty dependency array (the functional setter means it needs no deps).
✅ Solution
function List({ items }) {
const [picked, setPicked] = useState(null);
const onPick = useCallback((id) => setPicked(id), []);
return (
<ul>
{items.map(it => <Row key={it.id} item={it} onPick={onPick} />)}
</ul>
);
}
// Now only the row you click re-renders; the rest stay quiet.
🏋️ Exercise 2: Fix the stale closure
Goal: This search callback always searches the first term the user typed. Correct the dependency array so it uses the current term.
function Search() {
const [term, setTerm] = useState('');
const runSearch = useCallback(() => {
api.search(term); // always uses the very first term 🐞
}, []);
return (
<>
<input value={term} onChange={e => setTerm(e.target.value)} />
<button onClick={runSearch}>Search</button>
</>
);
}
✅ Solution
const runSearch = useCallback(() => {
api.search(term);
}, [term]); // recreate whenever `term` changes → always current
// (Alternatively, pass the term in: useCallback((t) => api.search(t), []).)
🎯 Quick Quiz
Question 1: What exactly does useCallback memoize?
Question 2: You memoize a callback with useCallback and pass it to a plain (non-memoized) child. What's the effect on re-renders?
Question 3: A useCallback reads userId but omits it from the dependency array. What bug results?
Best Practices & Pitfalls
✅ Do
- Use
useCallbackfor functions passed toReact.memochildren or listed in hook dependency arrays - Include every value the function reads in the dependency array
- Prefer the functional setter (
setX(prev => ...)) so callbacks don't depend on current state - Pass values as arguments when you can, to keep the dependency array empty
- Pair it with
useMemowhen stabilizing a whole context value object
❌ Don't
- Wrap every event handler in
useCallback— inline handlers on plain DOM elements are fine - Drop a dependency to stop a function from "changing" — that ships a stale closure
- Expect
useCallbackto help a child that isn't memoized - Depend on a whole object (
[config]) when you only read one field ([config.threshold])
⚠️ Narrow your dependencies
// 🔴 Recreated whenever ANY field of config changes reference
const filter = useCallback((data) =>
data.filter(x => x.value > config.threshold), [config]);
// ✅ Depend only on the value you actually use
const filter = useCallback((data) =>
data.filter(x => x.value > config.threshold), [config.threshold]);
Depending on a whole object reintroduces the very referential-equality problem you're trying to avoid. Depend on the primitive field instead.
Summary
🎉 Key Takeaways
- Functions get a new identity every render, which defeats
React.memoon the receiving child useCallback(fn, deps)returns the same function reference until a dependency changes- It only helps when the callback feeds a memoized child or a hook dependency
- Missing dependencies cause stale closures — real bugs, not just perf issues
useCallback(fn, d)is exactlyuseMemo(() => fn, d)- Don't over-use it — inline handlers on plain elements are simpler and just as fast
📚 Additional Resources
- react.dev —
useCallback - react.dev —
useMemo - react.dev —
memo - react.dev — Reasoning about dependency arrays
🚀 What's Next?
You've squeezed re-renders out of individual components. The next lesson zooms out to the whole bundle: Code splitting and lazy loading — using React.lazy and Suspense to load only the JavaScript a user actually needs, when they need it.
🔗 Locked in!
Function identity used to be an invisible trap. Now you can pin it down deliberately — and know when it's not worth the trouble.