We've all done it. You're reviewing a PR or looking at old code you wrote six months ago, and there it is: useMemo wrapped around a function that returns a string. Or useCallback on an event handler that gets passed nowhere interesting. It's everywhere. And it's mostly doing nothing.
The problem is that memoization feels like performance work. You're caching things. You're preventing re-renders. You're being smart. Except most of the time you're adding complexity with zero measurable benefit and making the code harder to read for the next person.
Let's talk about when memoization actually matters, when it actively makes things worse, and how to develop an intuition for the difference without spending two hours with the React DevTools profiler every time.
What useMemo and useCallback Actually Do
Before we debate when to use them, let's be precise about what they do. useMemo caches the result of a computation between renders. useCallback caches a function reference between renders. That's it.
// useMemo: caches a computed value
const expensiveResult = useMemo(() => {
return heavyComputation(data);
}, [data]);
// useCallback: caches a function reference
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
// These two are equivalent:
const fn = useCallback(() => doThing(x), [x]);
const fn = useMemo(() => () => doThing(x), [x]);
// useCallback is just syntax sugar for useMemo returning a functionMemoization isn't free. Every useMemo and useCallback call still runs on every render — React has to check whether the dependencies changed. The payoff only comes if what you're skipping (the computation, or the referential equality check downstream) costs more than that comparison. Most of the time, it doesn't.
The Three Situations Where useMemo Actually Helps
There are really only three scenarios where reaching for useMemo makes sense.
- Genuinely expensive computations — filtering/sorting large arrays, heavy data transformations, things that take measurable milliseconds
- Stable references for useEffect dependencies — when an object or array is used in a dependency array and you need it to not change identity on every render
- Props passed to memoized child components — when a child is wrapped in React.memo and you're passing objects/functions as props
That third one is the trickiest because it creates a chain: React.memo only helps if the props don't change every render, which means you need useCallback for function props and useMemo for object props passed to that component. If you forget one piece, the whole optimization falls apart.
// This is the pattern that actually works:
const Parent = ({ items }: { items: Item[] }) => {
// Without useMemo, this object is new on every render
// MemoizedChild will re-render every time regardless of React.memo
const config = useMemo(
() => ({ sortBy: 'name', limit: 10 }),
[] // stable — no dependencies
);
const handleSelect = useCallback(
(id: string) => {
console.log('selected', id);
},
[] // stable — no dependencies
);
return (
<MemoizedChild
config={config}
onSelect={handleSelect}
/>
);
};
const MemoizedChild = React.memo(({ config, onSelect }: ChildProps) => {
// This now only re-renders when config or onSelect actually change
return <div onClick={() => onSelect('123')}>{config.sortBy}</div>;
});The Expensive Computation Test
"Expensive" is doing a lot of work in that first bullet point. What counts? A good rule of thumb: if you can't see it in the profiler, it's not expensive enough to memoize. Most JavaScript operations — even ones that feel heavy — complete in under a millisecond. String formatting, array map/filter on a few hundred items, simple object construction: none of these need memoization.
// Don't do this — filtering 10 items isn't expensive
const filtered = useMemo(
() => items.filter(item => item.active),
[items]
);
// Maybe worth it — complex transform on thousands of items
const processedData = useMemo(
() =>
largeDataset
.filter(row => row.status === 'active')
.map(row => ({
...row,
score: calculateComplexScore(row),
label: formatLabel(row.name, row.category),
}))
.sort((a, b) => b.score - a.score),
[largeDataset]
);
// Quick sanity check: measure before optimizing
console.time('transform');
const result = expensiveTransform(data);
console.timeEnd('transform');
// If this logs < 1ms, don't bother with useMemoWe once spent an afternoon hunting a performance issue in a data table, convinced the problem was re-renders. Turns out it was a useEffect that fired on every keystroke and made a network request. All the useMemo we'd sprinkled around was irrelevant. Profile first, optimize second.
useCallback: The Most Overused Hook in React
useCallback gets cargo-culted more than useMemo. People wrap every function in it because "functions change on every render" and that sounds scary. But a function changing identity doesn't matter unless something downstream cares about that identity.
// Useless useCallback — onChange isn't passed to a memoized component
// and isn't in any dependency array
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
},
[]
);
return <input onChange={handleChange} />;
// This is exactly the same behavior, simpler:
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return <input onChange={handleChange} />;The built-in DOM elements (input, button, div) don't care about referential equality of event handlers. They re-render based on React's reconciliation, not prop identity. useCallback on handlers passed to native elements is essentially a no-op that costs you a dependency array to maintain.
Where useCallback earns its keep is when that function ends up in a useEffect dependency array, or when it's passed as a prop to a React.memo'd child. Those are the two real use cases.
// Legitimate useCallback — used in useEffect dependency array
const fetchData = useCallback(async () => {
const result = await api.get(`/items/${userId}`);
setItems(result.data);
}, [userId]); // re-create only when userId changes
useEffect(() => {
fetchData();
}, [fetchData]); // stable reference prevents infinite loop
// Without useCallback here, fetchData would be new on every render
// causing useEffect to run on every render — a real bug, not just wasteWhen Memoization Makes Things Worse
This doesn't get talked about enough: memoization can introduce bugs and make debugging significantly harder.
The most common issue is stale closures. You wrap something in useCallback with a dependency array, miss a dependency, and now your function is reading old values. ESLint's exhaustive-deps rule catches most of these, but when you're deep in a complex component with a dozen state variables, it's easy to end up with behavior that only manifests under specific conditions.
// Classic stale closure bug
const [count, setCount] = useState(0);
const [multiplier, setMultiplier] = useState(2);
// Bug: missing 'multiplier' in dependencies
// This will always use the initial multiplier value
const getResult = useCallback(() => {
return count * multiplier; // multiplier is stale!
}, [count]); // should be [count, multiplier]
// If you're getting weird values and can't figure out why,
// stale closures in memoized functions are suspect #1There's also the over-memoization spiral. You add useMemo to a value, then the component that receives it needs useCallback to stay stable, then that causes another component to need React.memo, and suddenly you've got memoization five layers deep for something that was rendering fine in 3ms to begin with. We've built ourselves into this corner more than once.
Memoization is a tool for fixing specific, measured performance problems — not a default coding style. If you can't point to a profiler screenshot or a concrete bug it prevents, it probably shouldn't be there.
A Decision Framework That Actually Works
Here's the mental model we use when writing new components or reviewing PRs. Run through these questions in order:
- Is there a measured performance problem? If not, don't add memoization preemptively.
- Is this computation taking > ~5ms? Use the console.time trick. If no, don't useMemo.
- Is this function/object used in a useEffect dependency array? If yes, useCallback/useMemo to prevent infinite loops.
- Is this passed as a prop to a React.memo'd component? If yes, you need stable references — use useCallback/useMemo.
- Is this prop passed to a native DOM element? If yes, skip it — irrelevant.
- Did you add all dependencies to the dependency array? If you're tempted to omit one, that's a red flag.
Most hooks pass questions 1-2 and stop there. That's fine. Write the simple version first, profile if something's slow, then add memoization surgically.
The React Compiler Changes This Conversation
Worth mentioning: React 19's compiler (formerly React Forget) automatically adds memoization where it's useful. If you're on a newer React version and have the compiler enabled, a lot of this manual work goes away. The compiler is better than humans at knowing where memoization helps — it has the full picture of your component tree and doesn't make the stale closure mistake.
This is actually an argument for writing simple code now. If you write clean components without premature optimization, the compiler can reason about them more easily. Dense, manually-optimized code with interdependent memoization chains is harder for the compiler to improve.
// With React Compiler, write this:
const MyComponent = ({ items, onSelect }: Props) => {
const filtered = items.filter(item => item.active);
const handleClick = (id: string) => {
onSelect(id);
};
return (
<ul>
{filtered.map(item => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
};
// The compiler figures out what to memoize
// You write readable code, React handles the rest
// Check if your React version supports it: npx react-compiler-healthcheckWe've been migrating our templates at peal.dev toward this pattern — clean components, minimal manual memoization, let the compiler earn its keep. The code is noticeably easier to read and maintain.
The Real Performance Wins Are Elsewhere
Here's the uncomfortable truth: if your React app feels slow, useMemo is probably not the fix. The highest-ROI performance work usually lives in:
- Reducing unnecessary network requests (too many fetches, waterfall data loading)
- Code splitting — loading less JavaScript upfront with dynamic imports
- Virtualization for genuinely long lists (react-virtual, tanstack-virtual)
- Moving state down — putting state closer to where it's used so fewer components re-render
- Fixing actual re-render causes with React DevTools profiler, not guessing
Moving state down is underrated. A huge component that holds state used by only one child will re-render everything when that state changes. Extract the stateful piece into its own component and suddenly you've eliminated dozens of unnecessary re-renders — no memoization required.
Nine times out of ten, a 'slow React app' is actually a slow network request, too much JavaScript in the initial bundle, or state living too high in the tree. Reach for those fixes before you touch useMemo.
The practical takeaway: delete the useMemo and useCallback calls you can't justify with a specific reason from the framework above. Your bundle gets slightly smaller, your code gets easier to follow, and you haven't lost any meaningful performance. Then, when you do have a real performance problem, you'll have a cleaner codebase to profile and a sharper instinct for where the bottleneck actually lives.
