Stop Wrapping Everything in useMemo
Stop Wrapping Everything in useMemo#
Stop. Put down the useMemo. Step away from the React.memo wrapper.
I mean it. Close the PR, open the React DevTools Profiler, and actually measure something before you add one more memoization wrapper to your codebase. I'll wait.
TL;DR
React.memo,useCallback, anduseMemoall have a real cost -- they're only worth it when the benefit exceeds that cost- The common case where they help: a child component wrapped in
React.memoAND a parent passes it a function/object prop that changes every render- Profile first with React DevTools. Always. Every single time.
- "Expensive" means measurably slow, not "this function looks complicated"
In code reviews, a common pattern emerges: the more junior the developer, the more useCallback and useMemo calls in their code. It looks like diligence. It feels like optimization. But it is actually overhead dressed up as performance work.
"It can't hurt, right?" It can. Let me show you why.
The Cost You're Always Paying#
Every call to useCallback or useMemo has to:
- Allocate a new array for the dependency list on every render
- Compare each dependency with the previous value (using
Object.is) - Decide whether to reuse the cached value or compute a new one
- Store the cached value and the deps in memory
For a component rendering hundreds of times per second (real-time chart, animation), this overhead is nothing compared to the savings. For a button that re-renders when you click it? The overhead probably exceeds any savings.
// This is NOT free
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
// For many components, this is equivalent and cheaper:
const handleClick = () => doSomething(id);
Myth #1: "React.memo Makes Components Faster"#
Not by itself, it doesn't.
React.memo wraps a component and tells React: "only re-render this if its props changed." React compares old and new props using shallow equality.
import { memo } from 'react';
interface ProductRowProps {
product: Product;
isSelected: boolean;
onSelect: (id: string) => void;
}
const ProductRow = memo(function ProductRow({ product, isSelected, onSelect }: ProductRowProps) {
return (
<tr className={isSelected ? 'row--selected' : ''}>
<td>{product.name}</td>
<td>{product.sku}</td>
<td>{product.price.toFixed(2)}</td>
<td>
<button onClick={() => onSelect(product.id)}>
{isSelected ? 'Deselect' : 'Select'}
</button>
</td>
</tr>
);
});
Looks helpful. But here's the thing -- if the parent re-renders and passes a new function reference for onSelect every time, React.memo is completely useless. The props ARE different. The function reference is new.
function ProductTable({ products }: { products: Product[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// new function reference every render -- React.memo can't help you
function handleSelect(id: string) {
setSelectedId(id);
}
return (
<table>
{products.map((p) => (
<ProductRow
key={p.id}
product={p}
isSelected={p.id === selectedId}
onSelect={handleSelect} // new ref every time!
/>
))}
</table>
);
}
This is why React.memo, useCallback, and useMemo tend to appear together. They're a system, not individual tools.
Myth #2: "useCallback Makes Functions Faster"#
No. useCallback doesn't make your function run faster. It gives your function a stable reference across renders, so that a child wrapped in React.memo can skip re-rendering:
function ProductTable({ products }: { products: Product[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// stable reference -- setSelectedId is guaranteed stable by React
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
return (
<table>
{products.map((p) => (
<ProductRow
key={p.id}
product={p}
isSelected={p.id === selectedId}
onSelect={handleSelect} // same reference across renders
/>
))}
</table>
);
}
NOW React.memo can do its job. But is it actually faster? Depends on how expensive rendering ProductRow is. If it renders instantly, the memoization overhead is pure cost. If you have 200 rows with complex content, the savings are real.
My take: I only reach for this combo when I can see the problem in the Profiler. Not before.
Myth #3: "useMemo Should Wrap Any Computation"#
useMemo is for computations that are genuinely expensive. And "expensive" means you measured it, not that the code looks complicated.
import { useMemo } from 'react';
function SalesSummary({ transactions, dateRange }: SalesReport) {
// This IS legitimately expensive: filtering + reducing thousands of records
const summary = useMemo(() => {
const filtered = transactions.filter(
(t) => t.date >= dateRange.start && t.date <= dateRange.end
);
return filtered.reduce(
(acc, t) => ({
totalRevenue: acc.totalRevenue + t.amount,
transactionCount: acc.transactionCount + 1,
averageOrderValue: 0, // computed below
byCategory: {
...acc.byCategory,
[t.category]: (acc.byCategory[t.category] ?? 0) + t.amount,
},
}),
{ totalRevenue: 0, transactionCount: 0, averageOrderValue: 0, byCategory: {} as Record<string, number> }
);
}, [transactions, dateRange]);
const averageOrderValue = summary.transactionCount > 0
? summary.totalRevenue / summary.transactionCount
: 0;
return (
<div>
<p>Revenue: {summary.totalRevenue.toFixed(2)}</p>
<p>Orders: {summary.transactionCount}</p>
<p>Average: {averageOrderValue.toFixed(2)}</p>
</div>
);
}
That's a legitimate use. Thousands of transactions, filtering and reducing on every render? Yeah, memoize that.
Now here's what is NOT legitimate:
// Pointless -- string concatenation is not expensive
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
// Pointless -- sorting 20 items is fast
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
// Unless items has 10,000+ entries AND this component re-renders constantly
My rough heuristic: if the computation takes less than 1ms, the memoization overhead may exceed the savings. Measure it:
// Quick measurement before committing to useMemo
console.time('sort-and-group');
const result = expensiveTransformation(data);
console.timeEnd('sort-and-group');
// if this prints < 1ms, skip the useMemo
The Real Problem: Referential Equality#
Most performance issues in React come down to referential equality. In JavaScript, {} !== {} even if the contents are identical. Functions too: () => {} !== () => {}.
// Creates a NEW object every render
function DataGrid({ rows }: { rows: Row[] }) {
const tableConfig = { striped: true, compact: false, pageSize: 50 };
return <Table config={tableConfig} rows={rows} />;
}
// Just move it outside the component. No hook needed.
const TABLE_CONFIG = { striped: true, compact: false, pageSize: 50 };
function DataGrid({ rows }: { rows: Row[] }) {
return <Table config={TABLE_CONFIG} rows={rows} />;
}
Moving static values outside the component is almost always better than useMemo. No hook overhead, no dependency array, just a module-level constant.
When I Actually Use These Tools#
After years of React in production, the pattern is almost always the same: a list of many items, each rendered by a memoized child component, with a stable callback handler via useCallback. That specific combination -- list + memo + callback -- is where these tools pay off.
For computations, the bar is simple: can you see this taking time in the Profiler? If yes, useMemo. If no, leave it out.
Start without memoization. Add it when you measure a problem. Your code will be simpler, your mental overhead will be lower, and you won't accidentally over-memoize half your codebase.
Common Pitfalls#
Inline arrays in dependency lists. If your useMemo depends on an array created inline in the parent, you get a new reference every render and the memoization never kicks in:
// BAD: filters is a new array every render
function SearchResults({ query }: { query: string }) {
const filters = ['active', 'verified']; // new reference!
const results = useMemo(() => search(query, filters), [query, filters]);
// useMemo never uses its cache because filters changes every render
}
// GOOD: stable reference
const DEFAULT_FILTERS = ['active', 'verified'];
function SearchResults({ query }: { query: string }) {
const results = useMemo(() => search(query, DEFAULT_FILTERS), [query]);
}
useCallback without React.memo on the child. This is the most common form of pointless useCallback. If the child re-renders anyway because it's not memoized, a stable function reference gives you exactly zero benefit.
useMemo for JSX. Memoizing JSX directly with useMemo is almost never right. If a sub-tree is expensive to render, use React.memo on the child component, not useMemo wrapping JSX in the parent.
Profile first. Optimize second. The best performance fix is the one you didn't have to write.