React.memo, useMemo and useCallback: The Honest Guide to When You Need Them
Stop spraying React.memo everywhere. Here's when useMemo, useCallback, and memo actually help — and when they silently make things slower.
The Myth: Memoize Everything and It Gets Faster
Somewhere around 2021, a wave of tutorials told everyone to wrap every component in React.memo and every value in useMemo. The reasoning sounded logical on the surface: fewer re-renders equals better performance. It doesn't work like that.
Memoization has a cost. Every time React skips a render because of memo, it still has to run a comparison. For every useMemo or useCallback call, React allocates a dependency array, stores the previous value, and does a shallow equality check on each render. If the thing you're memoizing is cheap to compute, you've just made your app slower — not faster.
Honestly, the number of production codebases I've seen where React.memo is slapped on a component that re-renders exactly once per user session is staggering. The memoization overhead is pure waste in those cases. Real performance work starts with understanding *when* re-renders are expensive, not assuming they always are.
Before you reach for any of these three tools, open React DevTools Profiler and actually look at what's slow. That one step will save you hours of speculative optimization.
React.memo: Shallow Comparison on Props, Nothing More
React.memo wraps a function component and tells React: 'Only re-render this if the props actually changed.' It uses shallow equality — same reference for objects and arrays, same primitive value for strings and numbers. That's it. No deep comparison, no magic.
The canonical use case is a component that receives the same props across many parent re-renders. Think a static sidebar, a header with user info that rarely changes, or a list item in a virtualized list where you control exactly which rows get updated.
const UserAvatar = React.memo(function UserAvatar({ userId, size = 40 }) {
const user = useUserStore(userId);
return (
<img
src={user.avatarUrl}
width={size}
height={size}
alt={user.name}
/>
);
});Where it falls apart: if the parent passes an inline object or inline function as a prop — <UserAvatar style={{ marginTop: 8 }} /> — you get a new object reference on every render. React.memo sees different props every time and re-renders anyway. You've paid the comparison cost for zero gain. Worth noting: this is exactly why memo and useCallback are often talked about together.
One more thing — React.memo accepts a second argument, a custom comparison function. Reach for that only if you need deep equality on specific fields. Don't write a generic deep-equal comparison; that's almost always the wrong call and slower than just letting React re-render.
useMemo: For Expensive Computations, Not Property Drilling
useMemo caches the return value of a function between renders. You give it a factory function and a dependency array. If the deps haven't changed, React hands you the cached value. If they have, it runs the function again.
const filteredItems = useMemo(() => {
return items.filter(item => item.category === activeCategory)
.sort((a, b) => b.score - a.score);
}, [items, activeCategory]);That's a legitimate use — filtering and sorting a potentially large array on every keystroke would be wasteful. But here's the rule of thumb most guides skip: if the computation takes less than ~1ms, useMemo's overhead is comparable to or greater than just running it. For a list of 20 items? Just filter it. For 50,000 items with a complex sort comparator? Yes, memoize it.
In practice, useMemo shows up in three genuinely good scenarios: expensive data transformations (parsing, sorting, filtering large datasets), referential stability (you need a stable object/array reference to pass into a memoized child or a hook's dependency array), and derived selector patterns in state management. Outside those three? Skip it.
Quick aside: useMemo does NOT replace state. If a value needs to trigger a re-render when it changes, it belongs in useState or a store. useMemo is purely a caching layer inside a render — it doesn't interact with React's update cycle.
useCallback: Stable Function References, That's the Whole Job
useCallback is useMemo for functions. useCallback(fn, deps) is literally equivalent to useMemo(() => fn, deps). The separate API exists for readability, not because it does anything fundamentally different.
You need useCallback when you're passing a callback prop into a memoized child component. Without it, the parent creates a new function instance on every render, the child sees a new prop reference, and React.memo's comparison fails every time.
const handleDelete = useCallback((id) => {
dispatch({ type: 'DELETE_ITEM', payload: id });
}, [dispatch]);
// Now this memoization actually works
return <ItemList items={items} onDelete={handleDelete} />;The other case: functions in dependency arrays. If you have a useEffect that depends on a callback, and that callback gets re-created each render, you'll fire the effect every render. Wrapping the callback in useCallback with the right deps breaks that cycle.
Look, useCallback without a memoized consumer is pointless. If <ItemList> isn't wrapped in React.memo, the stable callback reference doesn't buy you anything — ItemList will re-render whenever its parent does, callback or not. Always ask: 'Is there a memoized component or hook dependency downstream that needs this stability?' If not, drop it.
The Dependency Array: Where Bugs Actually Live
Most performance bugs with these three APIs aren't about whether to use them — they're about getting the dependency array wrong. Too broad and you invalidate the cache constantly. Too narrow and you introduce stale closures.
The eslint-plugin-react-hooks exhaustive-deps rule exists for a reason. Don't disable it. Don't add // eslint-disable-line and move on. If the rule flags something, understand why before working around it. Nine times out of ten it's pointing at a real bug.
// Stale closure bug — count never updates inside the effect
const handleSubmit = useCallback(() => {
console.log('Current count:', count); // always 0
}, []); // missing 'count' dep
// Fixed
const handleSubmit = useCallback(() => {
console.log('Current count:', count);
}, [count]);If you find yourself fighting the deps rule because adding a dep causes infinite loops, that's usually a sign of a deeper architectural issue — often that an object or function is being created inline inside a render rather than stabilized upstream. Trace the instability to its source instead of patching the symptom.
That said, there are a handful of values that are genuinely stable and safe to omit: state setter functions from useState, dispatch from useReducer, refs from useRef. React guarantees those are stable across renders. The exhaustive-deps rule knows about these and won't flag them.
A Decision Framework That Actually Works
Here's how to think about it. Before touching any of these three APIs, ask yourself two questions: Is this component actually re-rendering more than expected? And is that re-render actually causing a user-visible performance problem? If the answer to either is no, stop. Put the code back. Premature optimization in React in 2026 is still a trap.
If you've confirmed there's a real problem via profiling, then: use React.memo when a component has stable-ish props and its parent re-renders frequently for unrelated reasons. Use useMemo when a computation is genuinely expensive (benchmark it — 'feels slow' isn't a measurement). Use useCallback when passing callbacks to memoized children or into dependency arrays.
// Before: parent re-renders, Filter re-renders for no reason
function Dashboard() {
const [query, setQuery] = useState('');
const [sortBy, setSortBy] = useState('date');
return (
<>
<SearchBar value={query} onChange={setQuery} />
<SortControl value={sortBy} onChange={setSortBy} />
<ResultsFilter /> {/* no props, still re-renders */}
</>
);
}
// After: ResultsFilter is isolated, memo actually helps here
const ResultsFilter = React.memo(function ResultsFilter() {
const filters = useFilterStore(); // subscribes to its own slice
return <FilterPanel filters={filters} />;
});One pattern that sidesteps the whole problem: if a component only reads from a specific store slice or context value, pulling that subscription inside the component and keeping it out of the parent's render scope often eliminates the need for memo entirely. State colocation beats memoization every time.
If you're building heavily styled UI — the kind with animated glassmorphism cards, gradient overlays, or glassmorphism components — these same rules apply. The visual complexity doesn't change React's re-render model. A flashy component is still just a component. Browse components, profile what's actually slow, then optimize.
Common Patterns That Look Right But Aren't
Pattern one: memoizing a component that uses a context that changes frequently. If your component calls useContext(ThemeContext) and the theme updates on every interaction, React.memo won't save it — the component re-renders whenever context changes regardless of memo. Context updates bypass the memo comparison entirely.
Pattern two: chaining useMemo calls hoping each step stays cached. Each link in the chain re-computes when any upstream dep changes. If step one invalidates, everything downstream invalidates too. You haven't gained anything over a single computation; you've just added more overhead per render.
Pattern three — and this one trips up even experienced devs — using useCallback inside a loop or a conditional. That violates the rules of hooks and React will throw. If you need stable callbacks per list item, use a stable ID in the callback and let the handler figure out which item was targeted.
// Wrong: callback in a loop
items.map(item => {
const handleClick = useCallback(() => onClick(item.id), [item.id]); // Rules of Hooks violation
return <Item key={item.id} onClick={handleClick} />;
});
// Right: single stable handler, data-driven dispatch
const handleClick = useCallback((id) => {
onClick(id);
}, [onClick]);
return items.map(item => (
<Item key={item.id} id={item.id} onClick={handleClick} />
));The react-hooks-complete-guide covers the rules in depth if you want the full picture. And if you're building tools or dashboards with rich visual UI — the kind where every px of layout matters — check out the box shadow generator for getting shadow tokens right without manually tweaking values in code.
FAQ
No. The comparison cost adds up, and most components don't re-render often enough for memo to help. Profile first, then add memo where re-renders are measurably expensive.
It doesn't. useMemo caches a computed value between renders — it doesn't prevent the component from re-rendering. Only React.memo (or state structure changes) can skip renders.
Only when the function is passed to a memoized component or used in a hook's dependency array. Wrapping a callback in useCallback when neither condition is true buys you nothing.
No — useMemo runs synchronously during render and resets when the component unmounts. Use a data-fetching library like SWR or React Query for caching async work.