
A few weeks after I published my piece on useCallback vs useMemo, I started noticing a pattern in the codebases I was brought in to review. Teams had read the hook docs, understood referential equality, and gone to town — wrapping every function in useCallback, every derived value in useMemo, every component in React.memo. The apps weren't faster. In a few cases they were measurably slower, because now every render also paid the cost of dependency array comparisons, memo cache lookups, and extra closures being allocated on every pass.
Here’s the thing nobody tells you when they show you useMemo for the first time: memoization is not free, and it is not automatically a performance win. It's a trade — you're spending memory and comparison cost to avoid recomputation or re-rendering. If the thing you're avoiding wasn't expensive in the first place, you've just added overhead for nothing. Before you reach for those hooks, you need to know exactly which component is slow, why it's re-rendering, and what it's actually costing you in milliseconds. That's what the React Profiler is for, and that's what this article walks through — the diagnostic step that should come before any optimization, not after.
Why useCallback/useMemo Without Profiling Often Makes Things Worse
Let’s be concrete about the cost. Every useMemo and useCallback call does three things on every render: it evaluates the dependency array, it does an Object.is comparison on each dependency, and it either returns the cached value or re-runs the factory and re-caches. For a component that renders 200 times a second during a scroll event, that's 200 array comparisons you didn't need if the wrapped function costs less to just recreate than the hook costs to check.
Here’s a realistic example of the anti-pattern:
// BEFORE: reflexive memoization on a cheap computation
import { useCallback, useMemo } from 'react';
interface RowProps {
id: string;
label: string;
onSelect: (id: string) => void;
}function ExpensiveLookingRow({ id, label, onSelect }: RowProps) {
// This "memoization" costs more than the string concat it protects.
const displayLabel = useMemo(() => `Item: ${label}`, [label]);// Wrapping a trivial callback with no expensive children below it
// that actually check referential identity — pure overhead.
const handleClick = useCallback(() => {
onSelect(id);
}, [id, onSelect]);
return <li onClick={handleClick}>{displayLabel}</li>;
}Neither useMemo nor useCallback here is protecting anything expensive. String concatenation is nanoseconds. And unless ExpensiveLookingRow is wrapped in React.memo and onSelect/id are stable enough to matter, the useCallback buys nothing — the component re-renders anyway whenever its parent re-renders, so a fresh closure every time costs the same as a memoized one being returned every time.
The other failure mode is more insidious: memoizing the wrong dependency chain actually causes extra renders. If your useMemo dependency array includes an object or array that's recreated on every parent render (a very common bug, which we'll fix later in this article), the memoization never hits its cache — you pay for the comparison, then pay again for the recomputation, on every single render. You've made the code harder to read and no faster.
The lesson: don’t memoize because a hook exists. Memoize because you profiled a component, saw it re-rendering when it shouldn’t, or saw a computation taking real milliseconds on the flame graph. Let’s find those numbers.
Using React DevTools Profiler to Find Real Bottlenecks
Install the React Developer Tools extension (Chrome, Firefox, or Edge) if you don’t already have it. It ships two tabs: Components and Profiler. The Profiler tab is where actual performance work happens.
Recording a session
- Open your app in dev mode (profiling data is far less useful in a production build unless you use the profiling-enabled production bundle — more on that below).
- Open DevTools → Profiler tab.
- Click the record (circle) button.
- Interact with your app the way a real user would — type in a search box, scroll a list, open a modal.
- Stop recording.
You’ll get a series of commits — every time React actually flushed changes to the DOM, it shows up as a bar in the timeline at the top. Click through them one at a time.
Profiling with a production-like build
Dev mode React is slower than production React by design (extra checks, warnings, no minification), which distorts absolute timings. For accurate numbers, build with the profiling flag:
# using Create React App / a Vite-based setup with the profiling build of react-dom
npm run build -- --profile
Or, if you’re on Vite directly, alias react-dom to the profiling build in your build config:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
alias: {
'react-dom$': 'react-dom/profiling',
},
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
});This gives you real, non-inflated commit timings while still keeping the Profiler instrumentation active.
What to look for first
Don’t chase every colored bar. Look for two signals:
- Wide bars — commits that took a long time in absolute terms (the Profiler shows milliseconds per commit at the top).
- Frequent bars — commits that fire far more often than the interaction warrants. Typing one character in a search box should not trigger twenty commits across unrelated parts of the tree.
Frequency problems are usually re-render bugs (our next two sections). Width problems are usually genuinely expensive computation, and that’s where useMemo is actually justified.
Reading Flame Graphs and Commit Timings

Click on a single commit in the Profiler and you’ll see the flame graph (or the ranked chart — toggle between them with the icon in the toolbar). Here’s how to read it correctly.
The flame graph shows the component tree for that commit. Each bar is a component; width represents render duration relative to other components in that commit; color represents duration relative to the whole session (yellow/orange = slow relative to everything you’ve recorded, gray = didn’t render this commit, blue/green = fast).
A component that’s gray in a commit didn’t re-render at all — that’s what you want for most of your tree. A component that’s brightly colored and wide is where your time is going.
The ranked chart is more useful for triage — it lists every component that rendered in that commit, sorted by self-duration (time spent in that component, not counting its children). Start here. If your top five entries are components you didn’t expect to render at all, that’s a re-render bug, not a slowness bug.
Hover over any bar and DevTools tells you:
- Why did this render? (in newer versions, with “Record why each component rendered” enabled in profiler settings) — props changed, state changed, hooks changed, or parent rendered.
- Render duration for that component in that commit.
Turn on “Record why each component rendered while profiling” in the gear icon settings before you record. It is the single highest-leverage setting in the entire tool — it tells you directly whether a re-render was caused by changed props, changed state, or just “parent re-rendered,” which is exactly the question you need answered before deciding whether React.memo would even help.
A concrete example of a reading: say <ProductList> shows 40ms self-duration in a commit triggered by typing in an unrelated filter input, and hovering says "re-rendered because the parent component rendered." That tells you immediately: this isn't a slow computation problem, it's an unnecessary re-render problem, and no amount of useMemo inside ProductList will fix it — you need to stop the parent from forcing it to re-render at all, most likely with React.memo plus attention to what props are being passed down.
Identifying Unnecessary Re-renders with why-did-you-render
The Profiler tells you a render happened and roughly why, but for a fast, always-on signal during development, @welldone-software/why-did-you-render is worth wiring in. It patches React to log to the console exactly when a component re-renders for a reason that its own memoization should have prevented — most usefully, when props are deeply equal but not referentially equal.
Install it and set it up once, early, in your entry point:
// wdyr.ts — import this FIRST, before anything else, in your app entry point
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true, // flags every memo'd/PureComponent
trackHooks: true,
logOnDifferentValues: true,
});
}// index.tsx
import './wdyr'; // must be the very first import
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(<App />);With trackAllPureComponents: true, every component wrapped in React.memo gets automatically tracked. For components you haven't memoized yet but want to investigate, opt in explicitly:
// ProductCard.tsx
import { memo } from 'react';
interface ProductCardProps {
product: { id: string; name: string; price: number };
onAddToCart: (id: string) => void;
}function ProductCardImpl({ product, onAddToCart }: ProductCardProps) {
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => onAddToCart(product.id)}>Add to cart</button>
</div>
);
}const ProductCard = memo(ProductCardImpl);
// Tell why-did-you-render to track this component specifically
(ProductCard as any).whyDidYouRender = true;
export default ProductCard;
Now open your console and interact with the app. When ProductCard re-renders despite React.memo, you'll see a log like:
ProductCard re-rendered because of props changes:
onAddToCart (different references, but deeply equal? no, functions can't be deep-compared)
product (different references, deeply equal? yes)
That last line — “different references, deeply equal: yes” — is the single most useful diagnostic in this entire workflow. It means the data didn’t change, only the object identity did, which means somewhere upstream you’re creating a new object or array on every render and handing it down as a prop. That’s almost always fixable, and it’s the single biggest source of wasted re-renders in real apps. Let’s fix it.
Fixing the Top 3 Causes of Wasted Renders

1. Inline objects and arrays created on every render
This is the bug why-did-you-render just caught above. Any object or array literal written directly in JSX props, or derived inline in a render body without memoization, is a brand-new reference every render — even if every field inside it is identical.
// BEFORE: new object literal on every render of ParentList
function ParentList({ products }: { products: Product[] }) {
const [cartCount, setCartCount] = useState(0);
return (
<div>
<span>Cart: {cartCount}</span>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
// New arrow function every render — new reference every time,
// defeats React.memo on ProductCard even though `product` itself
// hasn't changed.
onAddToCart={(id) => setCartCount((c) => c + 1)}
/>
))}
</div>
);
}
Every time cartCount changes, ParentList re-renders, which recreates the onAddToCart arrow function for every product in the list — so every ProductCard, even ones with completely unrelated data, thinks its props changed and re-renders.
// AFTER: stable callback reference via useCallback, moved out of the map
function ParentList({ products }: { products: Product[] }) {
const [cartCount, setCartCount] = useState(0);
// One stable function reference, created once, reused across every render
// and every ProductCard instance.
const handleAddToCart = useCallback((id: string) => {
setCartCount((c) => c + 1);
}, []); // no external dependencies — safe empty array
return (
<div>
<span>Cart: {cartCount}</span>
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
))}
</div>
);
}
This is the correct, targeted use of useCallback — not applied reflexively everywhere, but applied specifically because we profiled, saw ProductCard re-rendering due to a changing function reference, and confirmed via why-did-you-render that the underlying data was stable.
The same pattern applies to object props. If you’re doing style={{ color: 'red' }} or config={{ debounce: 300 }} inline in JSX, hoist the literal outside the component (if it never changes) or wrap it in useMemo (if it depends on props/state):
// BEFORE: new object every render, breaks memoized children
function SearchBox({ debounceMs }: { debounceMs: number }) {
return <Input config={{ debounce: debounceMs, trim: true }} />;
}
// AFTER: memoized, only changes when debounceMs actually changes
function SearchBox({ debounceMs }: { debounceMs: number }) {
const config = useMemo(() => ({ debounce: debounceMs, trim: true }), [debounceMs]);
return <Input config={config} />;
}
2. Context over-subscription
Every component that calls useContext(SomeContext) re-renders whenever that context's value changes — no exceptions, React.memo does not help here at all, because the re-render isn't coming from the parent, it's coming directly from the context provider.
The classic mistake is bundling unrelated state into one context value:
// BEFORE: one giant context, one object value recreated on every provider render
interface AppContextValue {
user: User;
theme: 'light' | 'dark';
notifications: Notification[];
}
const AppContext = createContext<AppContextValue | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User>(defaultUser);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [notifications, setNotifications] = useState<Notification[]>([]);// New object every render of AppProvider — every single consumer
// re-renders every time ANY of these three pieces of state changes.
const value = { user, theme, notifications };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}A component that only reads theme still re-renders every time a new notification arrives, because it's subscribed to the whole bundled object. Split contexts by how often each piece of state actually changes, and memoize each value independently:
// AFTER: split contexts, each memoized independently
const UserContext = createContext<User>(defaultUser);
const ThemeContext = createContext<'light' | 'dark'>('light');
const NotificationsContext = createContext<Notification[]>([]);
function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User>(defaultUser);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [notifications, setNotifications] = useState<Notification[]>([]);return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<NotificationsContext.Provider value={notifications}>
{children}
</NotificationsContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
// A component that only cares about theme now only re-renders on theme changes
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Click me</button>;
}
If splitting contexts isn’t practical (some values are genuinely related), at minimum wrap the value object in useMemo so consumers don't re-render just because the provider itself re-rendered for an unrelated reason:
const value = useMemo(() => ({ user, theme, notifications }), [user, theme, notifications]);This won’t stop a re-render when one of the three actually changes, but it stops the phantom re-renders caused purely by AppProvider re-rendering (say, from a parent state change) without any of its context values actually changing.
3. List re-renders
Lists compound every one of the problems above because whatever you get wrong happens N times instead of once. The two things to check, in order:
Keys. If you’re using array index as key, insertions/removals in the middle of a list force React to re-render and sometimes remount every item after the change point, because React can't tell which DOM node corresponds to which data. Always key by a stable, unique ID:
// BEFORE: index keys cause full re-render cascades on reorder/insert
{products.map((product, index) => (
<ProductCard key={index} product={product} onAddToCart={handleAddToCart} />
))}
// AFTER: stable identity keys let React match items correctly and skip
// re-rendering items that didn't move or change
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} />
))}
Row components not memoized. Without React.memo, every row re-renders whenever the parent list re-renders, regardless of key correctness — sorting the list, filtering it, or updating a sibling component all cascade into every row repainting. Combine a memoized row with the stable-props fix from cause #1 (below), and you get a list where only the rows that actually changed data re-render.
When React.memo Helps vs When It’s Dead Weight

React.memo shallow-compares props and skips re-rendering if none changed. It is not free — the comparison itself has a cost, and for components with many props, that cost adds up. Here's the decision framework I actually use, informed by what the Profiler shows you.
Memo helps when:
- The component renders relatively often (parent updates frequently) but its own props change rarely.
- The component’s own render is non-trivial (more than a handful of DOM nodes, or it does real work in the render body).
- You’ve confirmed via why-did-you-render or the Profiler’s “why did this render” panel that it’s re-rendering due to “parent rendered” with unchanged props.
// GOOD CANDIDATE: renders often (parent list re-renders on filter/sort),
// props are usually stable, render body has meaningful DOM output.
interface ProductCardProps {
product: Product;
onAddToCart: (id: string) => void;
}
const ProductCard = memo(function ProductCard({ product, onAddToCart }: ProductCardProps) {
return (
<div className="product-card">
<img src={product.imageUrl} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => onAddToCart(product.id)}>Add to cart</button>
</div>
);
});Memo is dead weight when:
- The component’s props change on essentially every render anyway (e.g., a component that receives live cursor position, or a timestamp that updates every second) — the comparison will fail every time, so you pay for the check and still re-render.
- The component is trivially cheap to render (a <span>{text}</span>) — the shallow comparison costs more than just re-rendering it would.
- It’s a leaf component that rarely appears more than a few times in the tree — the total savings are noise-level.
// BAD CANDIDATE: props change every render anyway, comparison is pure waste
const LiveClock = memo(function LiveClock({ timestamp }: { timestamp: number }) {
return <span>{new Date(timestamp).toLocaleTimeString()}</span>;
});
// timestamp updates every second by definition — memo will never
// skip a render here, it just adds a comparison on top of the real work.
A quick gut-check before adding memo anywhere: open the Profiler, record the interaction that re-renders this component, and check the ranked chart. If the component's self-duration is already under a millisecond, memoizing it is not going to move your total commit time in any way a user would notice. Spend your effort on the components that show up wide and orange, not the ones you assume are slow.
A Repeatable “Profile → Fix → Re-measure” Workflow
Here’s the loop I run on every performance ticket, and it’s worth turning into a habit rather than a one-off:
- Reproduce the janky interaction with the Profiler recording (production-flagged build, “record why each component rendered” enabled).
- Open the ranked chart for the worst commit and identify the top 3 self-duration offenders.
- For each offender, ask: did it re-render because its own state/props legitimately changed, or because the parent rendered and passed down something with a new reference? The Profiler’s “why did this render” panel answers this directly.
- If it’s a reference problem, trace it with why-did-you-render, find the offending inline object/array/function, and fix it at the source (hoist, useMemo, useCallback, or split context) rather than papering over it with memo on the child.
- If it’s a genuinely expensive computation, that’s where useMemo around the computation itself is justified — not around trivial derived values.
- Apply exactly one fix, then re-record the same interaction. Compare commit durations directly — the Profiler keeps prior recordings if you don’t clear them, so you can flip between “before” and “after” commits.
- Confirm the fix actually reduced render count or duration before moving to the next offender. If it didn’t, revert it — you’ve just added complexity for nothing, which is exactly the trap this whole article is about avoiding.
The discipline that matters most here is step 6: one fix at a time, re-measured immediately. It’s tempting to memoize five things you spotted while reading the code and then wonder why the app feels the same. Isolate the change, isolate the measurement, and you’ll build an accurate mental model of what actually matters in your specific app instead of a folklore checklist copied from a blog post.
Wrapping Up
The pattern to internalize: profiling is not a step you do once at the end to confirm your optimizations worked — it’s the step that tells you whether you need to optimize at all, and exactly where. Most re-render problems in real React apps come down to the same three root causes covered here — inline objects/functions breaking referential equality, over-broad context subscriptions, and unmemoized list rows — and all three show up clearly in a Profiler session with “why did this render” turned on.
If you’ve been following along, here’s what to try next in your own codebase:
- Enable the profiling build of react-dom in your build config and record your app's slowest known interaction.
- Turn on “record why each component rendered” and look at the ranked chart before touching a single line of code.
- Wire up why-did-you-render on your three most complex components and watch the console while you use the app normally for five minutes — you’ll likely be surprised by what’s re-rendering.
- Only after you have that data, go apply useCallback, useMemo, and React.memo surgically to the specific components the Profiler pointed at — which is exactly the territory my useCallback vs useMemo piece covers in depth once you know where to aim them.
Measure first. The hooks will still be there when you actually need them.
