Profiling React Re‑Renders Before Reaching for useMemo

In Brief
Do not add useMemo, useCallback or React.memo just because a component renders more than once. Profile the slow interaction, identify which state, prop, context update or calculation causes the expensive work, then fix the smallest measured bottleneck. Memoisation has its own cost and can hide misplaced state or unstable prop identities without making the interface noticeably faster.
Performance conversations in React often go wrong in the same way. Somebody notices that a component renders more than once, everybody gets nervous, and the codebase starts filling up with useMemo, useCallback, and React.memo before anyone has measured whether the work was actually expensive.
That instinct is understandable, but it is not a good optimisation strategy.
React re‑renders are normal. A rerender only becomes a real problem when the work inside it is expensive enough, or wide enough, to make the interface feel slow. If we skip that measurement step, we usually end up paying the complexity cost of memoisation without removing the actual bottleneck.
A Rerender is Not Automatically a Bug
React's job is to recalculate UI when state or props change. That means rerenders are expected.
The useful questions are:
- how often is this subtree rerendering
- how much work happens during those rerenders
- which interaction actually feels slow
- and which component is expensive enough to justify intervention
That is why profiling matters. It replaces hunches with timing.
Start with the React DevTools Profiler
The Profiler gives us a much better view than console logging render counts.
Console logs tell us that something rerendered. The Profiler tells us which interaction caused it, how long the commit took, which components were expensive, and which parts of the tree rerendered only because an ancestor changed.
A sensible workflow looks like this:
- Reproduce the slow interaction, such as typing into a search box or opening a dense panel.
- Record the interaction in the Profiler.
- Find the slow commit rather than the noisiest component.
- Inspect which components were expensive and why they rendered.
- Change one thing, then profile again.
That last step matters. If the optimisation did not improve the measured interaction, it probably was not the right optimisation.
"Why Did This Render?" Is Often More Useful than Render Count
Suppose a page contains a search field, a filter drawer and a results list. These composition excerpts use the application's SearchInput and FiltersDrawer components; the ResultsList implementation appears below. They share the Product type and React imports shown here, and SearchPage receives products from its caller:
import { useMemo, useState } from 'react';
import type { JSX } from 'react';
type Product = {
id: string;
name: string;
};
const SearchPage = ({ products }: { products: Product[] }): JSX.Element => {
const [query, setQuery] = useState('');
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
return (
<>
<SearchInput
query={query}
onQueryChange={setQuery}
/>
<FiltersDrawer
isOpen={isFiltersOpen}
onClose={() => {
setIsFiltersOpen(false);
}}
/>
<ResultsList products={products} query={query} />
</>
);
};If opening the drawer causes the results list to rerender, that does not automatically mean ResultsList needs React.memo.
The better first question is why the results list is rerendering at all. In this example the answer is simple: state for the drawer lives in the parent, so the parent rerenders, which causes the child tree to rerender.
Sometimes the real fix is to move state closer to where it belongs:
const FiltersDrawerToggle = (): JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => {
setIsOpen(true);
}}
>
Open filters
</button>
<FiltersDrawer
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
/>
</>
);
};That often removes more wasted work than memoising half the tree.
useMemo helps only with specific kinds of cost
useMemo is useful when we have an expensive calculation whose inputs change less often than the component rerenders.
Here is the ResultsList used by that page, with the same Product type and imports. Both products and query are explicit inputs to its filtering calculation:
const ResultsList = ({
products,
query
}: {
products: Product[];
query: string;
}): JSX.Element => {
const filteredProducts = useMemo(() => {
return products.filter((product) => {
return product.name.toLowerCase().includes(query.toLowerCase());
});
}, [products, query]);
return (
<ul>
{filteredProducts.map((product) => {
return <li key={product.id}>{product.name}</li>;
})}
</ul>
);
};That can help if filtering is genuinely expensive or the list is large enough to matter.
It does not help if the real cost is elsewhere:
- a child subtree is still rerendering because props change identity
- context updates are hitting too much of the tree
- the list items themselves are expensive to render
- the bottleneck is layout, painting, or network latency rather than computation
That is why blindly wrapping every derived value in useMemo often leaves the interaction just as slow as before.
The Common Causes are Usually Structural
When profiling real React apps, the expensive rerender usually comes from one of a handful of patterns.
State is Higher than It Needs to Be
If a tiny UI concern lives near the top of the tree, unrelated subtrees rerender with it. The strongest optimisation is often a smaller ownership boundary rather than more memoisation.
Prop Identity Changes Keep Defeating Memoisation
If a memoised child receives a fresh object or callback every render, it still rerenders:
<ResultsToolbar
options={{ dense: true, showTotals: true }}
onReset={() => {
setQuery('');
}}
/>Sometimes useMemo or useCallback is justified here, but only if the child is memoised and profiling shows the rerender is worth avoiding. If not, the extra hooks are just ceremony.
Context Updates Fan Out Too Widely
Context is useful, but a broad context that changes frequently can rerender a large subtree. Splitting one busy context into smaller, more focused contexts is often more effective than memoising dozens of consumers.
Large Lists Need Different Tools
If the slow part is rendering hundreds or thousands of rows, no amount of light memoisation will beat list virtualisation or better data slicing.
Profile the Interaction, Not the Ideology
It helps to be specific about the interaction we are trying to improve:
- typing into a filter input
- opening a drawer
- switching tabs
- expanding a dense table row
Once the interaction is named, the optimisation path gets clearer. We can profile before and after, and we can tell whether the change improved what users actually feel.
That matters because a codebase can become more complicated whilst the interface stays just as sluggish. Memoisation is not free. It adds dependency arrays, identity reasoning, and more places for bugs or stale behaviour to creep in.
A Sensible Order of Fixes is Usually Enough
Before reaching for useMemo, I would usually check these in order:
- Is the slow interaction really a React render problem?
- Is state living too high in the tree?
- Is a broad context update rerendering too much?
- Is the expensive work computation, rendering, or simply too many nodes on screen?
- Would memoisation remove measured cost, or just make us feel safer?
Only after that would I start applying memoisation to the specific expensive path the profiler identified.
useMemo is a tool, not a performance posture
There is nothing wrong with using useMemo. It becomes a problem only when it replaces diagnosis.
Measured, targeted memoisation is often a good idea.
Habitual memoisation is usually a sign that the team is trying to optimise blind.
Useful References
The best React performance work usually looks less dramatic than people expect. We profile the slow interaction, identify the real source of cost, and then choose the smallest change that measurably improves it.
That is a much stronger habit than sprinkling useMemo around a component tree and hoping the browser feels grateful.