⚡ useMemo and useCallback Hooks
In the last lesson you saw that React.memo only pays off when its props keep stable references between renders. These two hooks are how you deliver that stability: useMemo caches a computed value, and useCallback caches a function — each recomputed only when its dependencies change.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Use
useMemoto cache expensive calculations and stabilize object/array props - Use
useCallbackto keep function references stable across renders - Explain how the two hooks relate and when each is the right tool
- Write correct dependency arrays and avoid missing/unnecessary dependencies
- Apply the functional update pattern to shrink dependency lists
- Recognize when memoization is unnecessary and adds cost without benefit
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Refactor a sluggish product filter using both hooks correctly.
In This Lesson
Two Problems, Two Hooks
React re-runs a component's entire function body on every render. That means two kinds of work can be repeated needlessly:
- Expensive calculations re-run even when their inputs didn't change.
- New function and object instances are created every render, breaking downstream
React.memo.
useMemo solves the first; useCallback solves the second. Both take a "create" argument and a dependency array, and both only redo their work when a dependency changes.
changed?} Q -->|No| Cache[Return cached
value / function] Q -->|Yes| Recompute[Recompute &
store new result] Cache --> Stable[Stable reference
keeps React.memo happy] Recompute --> Stable
📖 Analogy: A Graded-Test Notebook
A teacher who recalculates every student's score from scratch each time they're asked wastes hours. Instead, they grade once and jot each score in a notebook, then just read it back. useMemo is that notebook for values; useCallback is the same idea applied to reusable functions.
useMemo: Caching Values
useMemo memoizes the result of a calculation. It calls your create function on the first render, caches the return value, and on later renders returns the cached value unless a dependency changed.
const memoizedValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]); // recompute only when a or b changes
Use case 1 — expensive calculations
When a component does heavy work during render, wrap it so unrelated state changes don't retrigger it. Here, typing in an unrelated field won't re-run the filtering:
import { useMemo, useState } from 'react';
function Report({ rows, threshold }) {
const [note, setNote] = useState('');
// Only re-runs when `rows` or `threshold` changes — not when `note` does.
const highRows = useMemo(() => {
console.log('Filtering rows...');
return rows.filter(row => row.score > threshold);
}, [rows, threshold]);
return (
<div>
<input value={note} onChange={e => setNote(e.target.value)} />
<p>{highRows.length} rows above {threshold}</p>
</div>
);
}
Use case 2 — stabilizing object props
This is the direct fix for the shallow-comparison trap from the previous lesson. Wrapping a derived object in useMemo gives it a stable reference, so a memoized child can actually skip re-rendering:
import { memo, useEffect, useMemo, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// Stable object: only a new reference when `user` actually changes.
const info = useMemo(() => {
if (!user) return null;
return {
displayName: `${user.firstName} ${user.lastName}`,
email: user.email,
joined: new Date(user.joinDate).toLocaleDateString(),
};
}, [user]);
return <UserInfo info={info} />;
}
const UserInfo = memo(function UserInfo({ info }) {
console.log('UserInfo rendered');
if (!info) return null;
return (
<div>
<h2>{info.displayName}</h2>
<p>{info.email}</p>
<p>Joined {info.joined}</p>
</div>
);
});
💡 useMemo caches, it doesn't guarantee
React may discard a useMemo cache to free memory and recompute on the next render. Treat it as a performance optimization, never as a semantic guarantee — your create function must be pure and safe to run again at any time.
useCallback: Caching Functions
useCallback memoizes a function instead of a value. Without it, every render creates a fresh function; passing that to a memoized child breaks the memoization, because the function prop looks "changed" every time.
const memoizedFn = useCallback(() => {
doSomething(a, b);
}, [a, b]); // new function only when a or b changes
Here is the pattern in a to-do list. The handlers stay stable, so each memoized TodoItem only re-renders when its own todo changes:
import { memo, useCallback, useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
// Functional updates mean these never need `todos` as a dependency,
// so the references stay stable for the whole component lifetime.
const toggle = useCallback((id) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
}, []);
const remove = useCallback((id) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []);
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={toggle} onDelete={remove} />
))}
</ul>
);
}
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log('TodoItem rendered:', todo.text);
return (
<li style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
<input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});
⚠️ useCallback only helps a memoized recipient
Wrapping a handler in useCallback does nothing if the child that receives it isn't wrapped in React.memo — the child re-renders regardless, so the stable reference buys you nothing. The two tools work as a pair: memo on the child, stable references (useCallback/useMemo) on the props.
How They Relate
The two hooks are two faces of the same mechanism. useCallback(fn, deps) is exactly equivalent to useMemo(() => fn, deps) — a function that returns your function. useCallback is just the ergonomic shortcut for the very common case of memoizing a callback.
// These two produce the same stable function reference:
const a = useCallback(() => doThing(x), [x]);
const b = useMemo(() => () => doThing(x), [x]);
| Hook | Memoizes | Typical job |
|---|---|---|
useMemo | The result of calling a function | Expensive calc; stable object/array prop |
useCallback | The function itself | Stable event handler passed to a child |
Getting Dependencies Right
The dependency array behaves just like useEffect's: React recomputes when any listed value changes (using Object.is). Getting it wrong is the most common source of hook bugs.
[]— compute once and never again.- No array — recompute every render (pointless; defeats the hook).
[a, b]— recompute whenaorbchanges.
The rule is simple: every reactive value your create function reads must be in the dependency array. Omitting one gives you a stale closure that silently uses old data; adding an unrelated one causes needless recomputation.
// ❌ Missing dependency — uses a stale `cart`
const total = useCallback(() => {
return cart.reduce((sum, item) => sum + item.price, 0);
}, []); // ESLint: cart is missing
// ✅ Include what you read
const total = useCallback(() => {
return cart.reduce((sum, item) => sum + item.price, 0);
}, [cart]);
✅ Let the linter help you
The eslint-plugin-react-hooks rule react-hooks/exhaustive-deps flags missing and extra dependencies automatically. It ships enabled in most React setups (Vite, Next.js, Create React App). Treat its warnings as bugs to fix, not noise to silence.
Functional Updates: The Dependency Shrinker
A powerful trick: when a callback only needs the previous state to compute the next state, use the functional form of the setter. Because you no longer read the state variable directly, you can drop it from the dependency array — giving you a callback that never changes.
// Without functional update — must depend on `cart`
const updateQty = useCallback((id, qty) => {
setCart(cart.map(i => i.id === id ? { ...i, qty } : i));
}, [cart]); // changes every time cart changes
// With functional update — no dependency on `cart` at all
const updateQty = useCallback((id, qty) => {
setCart(prev => prev.map(i => i.id === id ? { ...i, qty } : i));
}, []); // stable forever
This pattern pairs beautifully with useCallback and memoized children: stable handlers plus memoized rows means updating one item re-renders only that item.
When Not to Memoize
Both hooks have a real cost: React stores the cached value/function and its dependencies, and compares dependencies every render. For cheap work, that bookkeeping outweighs any savings.
// ❌ Pointless — the calculation is trivial
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items]);
// ✅ Just compute it
const total = items.reduce((s, i) => s + i.price, 0);
// ❌ Pointless — nothing memoized consumes this handler
const onClick = useCallback(() => console.log('hi'), []);
// ✅ Just define it
const onClick = () => console.log('hi');
⚠️ Signs you're over-memoizing
- Profiler shows no measurable improvement.
- Code is noticeably harder to read, with dependency arrays everywhere.
- You're chasing stale-closure bugs caused by wrong dependencies.
- The app already felt responsive before you added the hooks.
Reach for useMemo/useCallback to fix a measured problem — an expensive calculation, or a reference feeding a memoized child — not as a reflex.
💡 A note on the future
The React team's compiler (React Compiler) can insert much of this memoization automatically, which will make many manual useMemo/useCallback calls unnecessary in projects that adopt it. Understanding the hooks by hand remains essential — it's what the compiler is doing for you, and what you'll still reach for in code it can't cover.
Hands-on Exercise
🏋️ Speed Up the Product Filter
Objective: This filter recomputes an expensive derived list on every keystroke and hands a new callback to every memoized row. Apply useMemo and useCallback so only necessary work happens.
import { memo, useState } from 'react';
const Row = memo(function Row({ product, onSelect }) {
console.log('Row render:', product.name);
return <li onClick={() => onSelect(product.id)}>{product.name} — ${product.price}</li>;
});
function Catalog({ products }) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(null);
// Runs on EVERY render, including when `selected` changes.
const results = products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
// New function every render → every Row re-renders.
const handleSelect = (id) => setSelected(id);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<p>Selected: {selected ?? 'none'}</p>
<ul>{results.map(p => <Row key={p.id} product={p} onSelect={handleSelect} />)}</ul>
</div>
);
}
Your tasks:
- Wrap the
resultscalculation so it only reruns whenproductsorquerychanges. - Stabilize
handleSelectso selecting an item doesn't re-render unaffected rows. - Confirm your dependency arrays are complete (no ESLint warnings).
💡 Hint
The filtering/sorting is a value — that's a useMemo job. The handler is a function — that's a useCallback job. Because handleSelect only calls the setter with an id, it needs no dependencies at all.
✅ Solution
import { memo, useCallback, useMemo, useState } from 'react';
const Row = memo(function Row({ product, onSelect }) {
console.log('Row render:', product.name);
return <li onClick={() => onSelect(product.id)}>{product.name} — ${product.price}</li>;
});
function Catalog({ products }) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(null);
// Recomputes only when products or query changes — not on selection.
const results = useMemo(() => {
return products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
}, [products, query]);
// Stable forever — no dependencies needed.
const handleSelect = useCallback((id) => setSelected(id), []);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<p>Selected: {selected ?? 'none'}</p>
<ul>{results.map(p => <Row key={p.id} product={p} onSelect={handleSelect} />)}</ul>
</div>
);
}
Now clicking a row updates selected without re-sorting the list or re-rendering the other rows. Typing re-filters, but the handler reference stays put.
🎯 Quick Quiz
Question 1: What is the key difference between useMemo and useCallback?
Question 2: Why can the functional update form setCart(prev => ...) shrink a dependency array?
Question 3: When is wrapping a handler in useCallback pointless?
Summary & Quiz
🎉 Key Takeaways
useMemocaches a computed value, recomputing only when its dependencies change — for expensive calcs and stable object/array props.useCallbackcaches a function; it'suseMemo(() => fn, deps)in disguise.- Both are only useful when a downstream
React.memo(or another hook's deps) actually consumes the stable reference. - List every reactive value you read in the dependency array; lean on
exhaustive-deps. - The functional update pattern removes state from dependencies, yielding permanently stable callbacks.
- Don't memoize cheap work — measure, then optimize the real hotspots.
📚 Further Reading
- React docs —
useMemo - React docs —
useCallback - React docs — You Might Not Need an Effect
- React docs — React Compiler
🚀 What's Next?
You've optimized how components render. Next we tackle a different axis of performance — how much JavaScript ships in the first place — with code splitting and lazy loading.
🎉 Great progress!
You now have the full memoization toolkit: memo, useMemo, and useCallback working together.