useCallback, useMemo, and React.memo: Everyone Knows What They Do — Few Know How to Use Them Together

Most React developers can tell you what useCallback, useMemo, and React.memo do. They've read the docs, used them individually, and they work fine in isolation. Then they put them together and the memoization silently stops working — the component re-renders anyway, the expensive computation runs on every keystroke, and no one knows why.
The problem isn't understanding each one. The problem is understanding the contract between them.
useCallback — Stabilize Function References
Every time a React component renders, every function defined inside it is recreated as a brand new object. Even if the function body is identical, it's a different reference in memory.
function Parent() {
// This is a new function object on every render
const handleClick = () => {
console.log("clicked");
};
}
useCallback fixes this by caching the function and returning the same reference across renders — until one of its dependencies changes:
function Parent() {
const [userId, setUserId] = useState(1);
// Same reference as long as userId doesn't change
const handleClick = useCallback(() => {
console.log("clicked", userId);
}, [userId]);
}
The dependency array works the same way as useMemo — list every value the function uses from outside itself. When those values change, useCallback creates a new function. When they don't, you get the cached one.
Click Re-render multiple times on both sides. On the left a new function is created every time. On the right the same reference is reused — it only changes when you click Change dep.
const handler = () => {};
// ↑ new reference every renderconst handler = useCallback(
() => {},
[dep] // only new when dep changes
);useMemo — Cache Expensive Values
React recomputes everything inside a component on every render. For cheap operations — building a string, adding two numbers — this is fine. For expensive ones — filtering a thousand records, building a tree from a flat list, running a sort — it adds up.
useMemo lets you cache the result and only recompute it when the inputs actually change:
Without useMemo:
function ProductList({ items, activeCategory }) {
// Runs on every render — even unrelated ones
const visible = items
.filter(p => p.category === activeCategory)
.sort((a, b) => a.price - b.price);
}
With useMemo:
function ProductList({ items, activeCategory }) {
// Only recomputes when items or activeCategory change
const visibleItems = useMemo(() => {
return items
.filter(p => p.category === activeCategory)
.sort((a, b) => a.price - b.price);
}, [items, activeCategory]);
}
If the parent re-renders for an unrelated reason — a different piece of state changed, a context updated — useMemo returns the cached result without touching the filter or sort.
useMemo also solves a less obvious problem: referential stability for objects and arrays. Inline objects and arrays are recreated on every render, just like inline functions:
// New object reference every render
const config = { pageSize: 20, sortBy: "date" };
// Stable reference — only changes when pageSize or sortBy change
const config = useMemo(() => ({ pageSize, sortBy }), [pageSize, sortBy]);
This matters when that object is passed as a prop to a memoized child — which we'll get to in a moment.
The demo below has two inputs — one that's a dependency of the memo, and one that isn't. Toggle useMemo on and type in the username field: the component re-renders but the computation is skipped. The results don't change, so there's no reason to redo the work.
Two inputs — one that changes the filter (a dep of the memo), one that doesn't. With useMemo OFF, the filter+sort over 800 items runs every time either input changes. With useMemo ON, typing in the username field triggers a re-render but the computation is skipped — the results don't change, so why redo the work?
React.memo — Skip Re-renders When Nothing Changed
By default, React re-renders a child component whenever the parent renders — regardless of whether the child's props changed. React.memo wraps a component and tells React to skip re-rendering it if all props are the same as last time:
const ExpensiveChart = React.memo(({ data, onSelect }) => {
// Only re-renders when data or onSelect actually change
return <Chart data={data} onSelect={onSelect} />;
});
React does a shallow comparison of each prop. For primitive values like strings and numbers, this works exactly as expected — "hello" === "hello" is true. But for objects, arrays, and functions, React compares references, not contents. Two objects with identical keys and values are still different references.
This is where most developers hit the wall.
The Problem: React.memo With Unstable Props
Here's the pattern that looks correct but doesn't do anything:
const ExpensiveList = React.memo(({ onItemClick, filters }) => {
// Expensive render...
});
function Dashboard() {
const [count, setCount] = useState(0);
// New function reference every render
const handleClick = (id) => { console.log(id); };
// New object reference every render
const filters = { category: "work", sort: "date" };
return (
<>
<button onClick={() => setCount(c => c + 1)}>+</button>
<ExpensiveList onItemClick={handleClick} filters={filters} />
</>
);
}
Every time count changes, Dashboard re-renders. handleClick gets a new function reference. filters gets a new object reference. React.memo does a shallow comparison, sees that both props changed, and re-renders ExpensiveList anyway. The React.memo wrapper is doing nothing.
The Fix: useCallback + useMemo Make React.memo Work
Stabilize every prop that would otherwise change on every render:
function Dashboard() {
const [count, setCount] = useState(0);
// Stable function reference
const handleClick = useCallback((id) => {
console.log(id);
}, []);
// Stable object reference
const filters = useMemo(() => ({
category: "work",
sort: "date",
}), []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>+</button>
<ExpensiveList onItemClick={handleClick} filters={filters} />
</>
);
}
Now when count changes and Dashboard re-renders, handleClick and filters have the same references as before. React.memo sees identical props and skips re-rendering ExpensiveList entirely.
Toggle useCallback below and click the re-render button — watch what happens to the child.
The child is wrapped in React.memo. Toggle useCallback and click Re-render parent — without it, the child re-renders every time even though nothing relevant changed.
// 💥 New fn every render — React.memo is bypassed
const handleClick = () => {};
<Child onClick={handleClick} />Rules That Actually Help
1. React.memo is useless without stable props. Wrapping a component in React.memo changes nothing if you pass inline functions or object literals. Audit every prop — each one needs to be stable or primitive.
2. When a child has React.memo, the parent needs useCallback and useMemo. One unstable prop bypasses the entire optimization. The work lives in the parent, not the child.
3. Don't memoize everything. Adding useCallback and useMemo has overhead — the cache lookup, the dependency comparison. Apply them when a memoized child exists or when a computation is measurably slow. Not by default.
4. useMemo doesn't prevent renders. It caches a value. Only React.memo (with stable props) prevents a child from re-rendering. These are separate problems.
5. The deps array is a contract. Every external value your callback or memo uses must be listed. Missing a dep means the function or value goes stale. The ESLint rule react-hooks/exhaustive-deps enforces this — treat it as load-bearing, not noise.
The three work as a system, not independently. useCallback and useMemo produce stable references. React.memo consumes them. Get one wrong and the others silently stop working.
Aziz Jarrar
Full Stack Engineer