Most react performance optimization advice is a listicle of techniques disconnected from anything your app actually does. Twelve hooks, ten patterns, one vague suggestion to “memoize where appropriate.” You close the tab. Your app is still slow.
Open React DevTools Profiler and a different picture appears. Almost every slow React app shows one of three flame chart patterns: child components re-rendering when their props didn’t change, one component doing expensive work on every render, or memoized components that still re-render anyway. Each pattern has exactly one fix that works — and one popular “fix” that usually makes things worse.
The question isn’t which technique to use. It’s which pattern is in your app right now, and what you’re probably already reaching for that you shouldn’t be.
How to Read a React Profiler Flame Chart in 60 Seconds
Before the patterns make sense, you need just enough Profiler literacy to read evidence instead of guess. This is the entire react profiler tutorial you need.
Open React DevTools → Profiler tab → hit record → interact with your app → stop. You get a series of “commits” — every render that ran while you were recording. Click any commit and you see a flame chart.
In the flamegraph view, each bar is a component. Width is render time. Color shifts yellow → orange → red as duration grows. Gray means “this component didn’t render in this commit” — which is what you want most components to look like most of the time.
Switch to ranked view for the same data sorted by render duration. The bar at the top is your bottleneck. That’s where you spend your attention.
Click any component and the right panel shows “Why did this render?” — props changed, state changed, hooks changed, or “the parent component rendered.” That single sentence routes you to which of the three patterns you’re looking at.
The one-line heuristic: if you see gray everywhere, you’re fine. If you see wide colored bars in components whose UI didn’t visually change, you have one of the three problems below. Now we can name yours.
Pattern 1: Child Components Re-render When Their Props Didn’t Change
The flame chart signature is unmistakable. A parent re-renders, and a strip of child bars beneath it all light up colored — even though nothing on screen visually changed. Click any of those children. The “Why did this render?” panel says: the parent component rendered.
The default reach is React.memo. Wrap the child, ship it, move on. That works when the props are primitives or stable references. It does nothing when the parent is passing a fresh object on every render — and we’ll get to that as Pattern 3.
There’s a better first move most engineers skip: move state down. If only one subtree of the parent actually depends on a piece of state, colocate the useState inside that subtree. Now the parent doesn’t re-render at all when that state changes, and the cascading children don’t either. No memo, no comparison overhead, no fragile equality contracts. The unnecessary re-renders just stop. For a deeper look at where your state should actually live, we compared every major option.
React.memo is correct in a narrower set of cases than people think. Leaf components with primitive props that render very often — list items, table cells, frequently updated cards. Or components whose render is genuinely expensive enough that a shallow prop comparison is cheaper than skipping it.
Verifying the fix is the part most react unnecessary re-renders fix posts skip. Re-record the same interaction in the Profiler. The previously colored child bars should now be gray. If they’re still colored, the props are changing — go look at what the parent is passing. If they’re gray, you’re done.
One honest caveat. If the child takes an object or array prop and you wrapped it in memo expecting magic, memo won’t help. That’s not Pattern 1. That’s Pattern 3, and the fix lives somewhere completely different.
But what if the child component is doing heavy work itself, not just rendering unnecessarily?
Pattern 2: One Component Bar Is Always Wide — Expensive Work on Every Render
The signature is different from Pattern 1. One component’s bar is consistently wide and red across multiple commits, while surrounding components stay gray. It’s not a cascade. It’s a single bar doing too much.
Common causes: sorting or filtering a large list inline in the render body, deriving a heavy data structure from raw props, parsing JSON, computing layout math. The work runs every time the component renders, even when its inputs didn’t change.
The fix is useMemo around the computation, with a dependency array that lists the values the computation actually reads:
const sortedRows = useMemo(
() => rows.slice().sort(byPriority),
[rows]
);
Now the sort runs only when rows changes.
Here’s the cost almost no react.memo useMemo useCallback post mentions: useMemo itself has overhead. Every render, it compares the dependency array and stores the cached value. For cheap calculations, that bookkeeping costs more than just doing the work. The rule of thumb: useMemo earns its keep when the calculation shows up as a measurably wide bar in the Profiler — call it more than 1ms. If you can’t see the bar, don’t memoize it.
To verify, re-record. The previously wide bar should shrink to thin or gray on commits where dependencies didn’t change. If it stays wide, your dependency array is wrong — usually because something inside the calculation isn’t listed.
The most common own-goal here is subtle. Engineers memoize the result but pass a new array literal as the dependency itself:
const filtered = useMemo(
() => rows.filter(r => r.active),
[rows, [/* this is a new array every render */]]
);
That second dep is a fresh reference every render, busting the cache every time. The memo does nothing, but you pay the comparison cost forever.
OK — but what if you did all this, memoized the value cleanly, and your child component still re-renders on every parent update?
Pattern 3: You Wrapped It in memo and It Still Re-renders
This is the pattern that makes engineers angrily Google “react memo not working.” The child is wrapped in React.memo. The Profiler still shows it re-rendering on every commit. Click it. “Why did this render?” — props changed.
The root cause is almost always the same: the parent is passing a new object literal, a new array literal, a new inline function, or a new style object as a prop. React.memo does a shallow === comparison. A new {} is never === to the previous {}, even if every field is identical.
Three fixes, in increasing order of preference:
- Pass primitives instead of objects when possible. Instead of
<List items={items} />, pass<List hasItems={items.length > 0} count={items.length} />if those are the only things the child actually uses. Primitives are stable by value. The memo wins. - Stabilize the reference at the source. If the child genuinely needs the array, lift the literal out of the render — define it as a module-level constant, or wrap it in
useMemo/useCallbackso its identity is stable across renders. - Restructure to avoid passing the value at all. Let the child read it from context, or colocate the state inside the child. The prop simply disappears.
useCallback has one narrow job in this picture: stabilizing function references passed to memoized children, or to hooks like useEffect that depend on identity. Outside those cases, it’s just overhead pretending to be optimization.
Verify the same way as the other patterns: re-record, click the child in the flame chart, and check the Props diff. If it’s empty on the commits where the child re-rendered, you have a different problem — but you almost never will. The fix lives at the source of the prop, not at the consumer.
If you’re doing all this manually in 2026, the obvious question is whether you should be.
The Optimization That Usually Makes Things Worse
Here’s the pattern I see in nearly every codebase audit. A developer reads that React is slow because of re-renders, wraps every component in React.memo and every derived value in useMemo, and benchmarks the result. Things are slower. Not faster. Slower.
Every React.memo adds a shallow prop comparison on every render. Every useMemo adds dependency tracking. The work it takes to decide whether to skip work often exceeds the work being skipped. In the Profiler, commit times go up. Hover over the new wide bars — they’re the memoization overhead itself, not the original render.
The worse failure mode is silent. You memoize a component whose parent always passes new object props. Now you pay the comparison cost every render and still re-render every time. You added a tax to a problem you didn’t fix. That’s a net negative dressed up as “optimization.”
This is where blanket memoization breaks the optimize react app performance 2026 narrative entirely. React 19’s React Compiler automatically memoizes only when it can prove the work is worth skipping. Manual blanket memoization predates that analysis and rarely matches it. The discipline is unchanged regardless of compiler: measure first, fix the wide bar in front of you, and leave the gray bars alone.
So should you just upgrade to React 19 and let the Compiler handle all of this?
What React Compiler Changes (and What It Doesn’t)
Mostly, yes — for what it covers. The React Compiler ships in React 19.2 (June 2025). It analyzes components at build time and inserts memoization where it provably helps. For most codebases, you can delete the React.memo wrappers and most useMemo/useCallback calls from new code and let the compiler handle it.
What it replaces well: cascading re-renders from Pattern 1, stable-reference issues from Pattern 3, and most of the speculative memoization people add to “be safe.” The compiler is better at this than you are, because it can see what’s actually used downstream.
What it doesn’t fix:
- Genuinely expensive synchronous work. A 50ms sort is still a 50ms sort. You still useMemo it, or you push it out of the render entirely (see web worker patterns that fix frozen UIs).
- Bad state placement. State at the wrong level is still state at the wrong level. Move state down, or the compiler will dutifully memoize around a problem that should have been deleted.
- Heavy lists. You still virtualize. The compiler is not a windowing library.
- References from outside your control. Hooks from libraries that return fresh objects every render bypass the compiler’s analysis. You still stabilize them by hand.
Add code splitting patterns to that list — the compiler optimizes renders, not bundle size.
The react concurrent features performance tools — useTransition for non-urgent updates like filter inputs and tab switches, useDeferredValue for stale-while-fresh rendering — remain explicit decisions. The compiler doesn’t choose them for you, because they involve user-experience tradeoffs, not just pure equality.
The workflow shift is small. Keep using the Profiler exactly the same way. Reach for the Compiler before reaching for memo. Reserve useMemo and useCallback for the specific cases above.
That leaves one thing: a one-glance rule for which fix to apply when.
The Decision Framework: Profiler Pattern → Fix
Keep this open next to your Profiler.
| What the Profiler shows | What to do |
|---|---|
| Cascading colored bars under an unchanged parent | Move state down first. React.memo only if props are already primitives. |
| One persistently wide bar in isolation | useMemo the expensive computation inside it — but only if you can see the bar in the Profiler. |
| A memoized child that still re-renders every commit | Fix the prop at the source: primitives, stable references, or context. |
| Commit times went up after you added optimizations | Remove them. Let React Compiler (or nothing) handle it. |
The Profiler was already telling you what’s wrong. The point of this article was to translate three flame chart patterns into three concrete moves — and stop you from reaching for the popular fourth one that makes things worse.
Open the Profiler. Find your widest bar. You already know what to do.