Wasted Renders
What you'll learn
- The three, and only three, things that cause a component to render
- What a "wasted render" actually is, and when it genuinely matters
- The render cost vs commit cost model that should drive every optimization decision
- How to measure renders before optimizing anything
- Three fixes, applied step by step: colocation,
React.memo, and the children-as-props pattern that surprises everyone
Your app feels laggy. Typing in a search box stutters. Someone on the team says "React is re-rendering too much" and reaches for React.memo. Sometimes that helps. Sometimes it does nothing. The difference between the two outcomes is understanding why components render at all, and what a render actually costs. This chapter builds that model, then uses it to fix a real laggy screen three different ways.
The three causes of a render
A component re-renders for exactly one of these reasons. There are no others.
1. Its own state changed. A useState or useReducer inside this component received an update, and React scheduled a render.
2. Its parent re-rendered. This is the one that surprises people. When a component renders, React by default re-renders all of its children, memo or not, whether their props changed or not. Rendering cascades down the tree until something stops it.
3. A context it consumes changed value. Any component calling useContext(SomeContext) re-renders when that context's value changes to something not Object.is-equal, even if the component is memoized. Context cuts through memo.
Jargon: "render cascade". The default behavior where rendering a component re-renders its entire child subtree, top to bottom, unless a memo boundary or identical-element bailout interrupts it.
Notice what's not on the list: props changing. Props changing is not a cause of rendering, it's a consequence of cause #2. The parent rendered, so the child renders, and the child happens to receive new props. Even a child whose props are byte-identical re-renders when its parent does. That's the default, and it's the source of most wasted work.
What "wasted render" actually means
Not every extra render is waste. Here's a precise definition:
A wasted render is a render whose returned element tree is identical to the previous one and whose subtree's DOM therefore needed zero changes.
React rendered, diffed, found nothing to do, and committed nothing. The DOM never moved. The work happened entirely in JavaScript and produced nothing.
The cost model: render vs commit
To judge whether a wasted render matters, you need to separate the two phases' costs:
- Render cost, calling your component functions, creating element objects, diffing trees. This is pure JavaScript: usually microseconds per component, usually cheap.
- Commit cost, mutating the real DOM, running layout effects, the browser's style recalculation, layout, and paint. This is what users actually feel.
Pseudocode model, not real source:
// One update, conceptually:const newTree = renderComponents(); // cheap-ish JS workconst changes = diff(oldTree, newTree); // more cheap JS workcommit(changes); // DOM ops — the expensive part// A wasted render: newTree deep-equals oldTree, changes is EMPTY.// You paid for render + diff and got nothing.
The punchline: a wasted render only matters when the render cost is big or the frequency is high.
- A wasted render of a 2,000-component tree with heavy computation? Real problem, that's hundreds of milliseconds of JS blocking the main thread.
- A wasted render on every keystroke or mousemove? Real problem, small costs multiplied by 60 events per second add up to dropped frames.
- A wasted render of a small subtree, once, when a user clicks a button? Almost certainly irrelevant. Do not optimize it.
Measure first, always
Optimizing without measuring is guessing. Two tools:
React DevTools Profiler. The Profiler tab in React DevTools records an interaction: you hit record, do the slow thing (type in the box, click the button), stop, and get a flame graph of exactly which components rendered, how long each took, and, crucially, why each one rendered. The "why did this render?" information tells you which of the three causes fired: state changed in this component, parent rendered, or a context value changed. That single piece of information usually points straight at the fix.
console.count, the poor-man's profiler. Drop one line in a component body:
function Chart({ data }) {
console.count('Chart rendered');
// ...expensive rendering work
return <svg>{/* hundreds of points */}</svg>;
}
What happens: every render logs Chart rendered: 1, Chart rendered: 2, and so on. If you type one character in an unrelated input and the counter jumps, you've proven a wasted render without opening any tooling. Crude, instant, effective.
Worked example: the laggy search page
Here's our patient. A page with a static sidebar, a search input, and a chart that is genuinely expensive to render:
import { useState } from 'react';
function Sidebar() {
return (
<nav>
<a href="/dash">Dashboard</a>
<a href="/reports">Reports</a>
<a href="/settings">Settings</a>
</nav>
);
}
function Chart({ data }) {
console.count('Chart rendered');
// Simulate genuinely heavy render work:
const points = data.map((d) => Math.sqrt(d) * Math.random());
return (
<svg viewBox="0 0 100 100">
{points.map((p, i) => (
<circle key={i} cx={i} cy={p} r="1" />
))}
</svg>
);
}
function SearchResults({ query }) {
return <p>Results for: {query || '—'}</p>;
}
export default function Page() {
const [query, setQuery] = useState('');
const data = [4, 9, 16, 25, 36 /* ...imagine thousands */];
return (
<div className="layout">
<Sidebar />
<main>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
/>
<SearchResults query={query} />
<Chart data={data} />
</main>
</div>
);
}
Diagnosis, what happens when you type one character:
onChangefires, callingsetQuery('a').- State lives in
Page, soPagere-renders (cause #1). - The render cascade fires:
Sidebar,SearchResults, andChartall re-render (cause #2), even thoughSidebarandChartreceived identical props. console.countshowsChart rendered: 2. The expensive function ran again.Sidebarrebuilt its elements too.- React diffs everything, finds only the input's
valueand the results text changed, commits two tiny DOM updates.
The commit was tiny. The render was expensive, mostly Chart. Typing feels laggy because cause #2 drags a heavy component through a full re-render on every keystroke. Profiler would show: "Chart rendered because the parent component rendered." Now the fixes.
Fix A: move the state down (colocation)
The state query lives in Page, but only the input and SearchResults use it. State should live in the smallest component that actually needs it. Push it down:
import { useState } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
/>
<SearchResults query={query} />
</>
);
}
export default function Page() {
const data = [4, 9, 16, 25, 36];
return (
<div className="layout">
<Sidebar />
<main>
<SearchBox />
<Chart data={data} />
</main>
</div>
);
}
What happens now:
- You type;
setQueryruns, but the state lives inSearchBoxnow. SearchBoxre-renders (cause #1). Its subtree is just the input and a<p>.Pagedoes not re-render at all. No parent rendered it, its state didn't change, it consumes no context.ChartandSidebarnever even get called. The counter stays put.
The whole problem evaporated, not by blocking renders, but by moving the render somewhere cheap. This is almost always the best fix, and it required zero memoization.
Fix B: wrap Chart in React.memo
Sometimes you can't colocate, say the chart genuinely needs page-level state. The next tool is a memo boundary:
import { memo, useState } from 'react';
const Chart = memo(function Chart({ data }) {
console.count('Chart rendered');
const points = data.map((d) => Math.sqrt(d) * Math.random());
return (
<svg viewBox="0 0 100 100">
{points.map((p, i) => (
<circle key={i} cx={i} cy={p} r="1" />
))}
</svg>
);
});
What happens now:
- You type;
Pagere-renders (state lives here again in this variant). - The cascade reaches
<Chart data={data} />. - React sees
Chartis memoized and compares props: isdatathe same as last render?datais re-created as a fresh array literal inPage… careful! In this snippetdatais a new array each render, so the shallow compare fails andChartstill renders. To make memo stick, hoist it:const data = [4, 9, 16, 25, 36];outside the component, or wrap it inuseMemo. - With
datastable, the compare passes, React skips callingChartentirely and reuses its previous output.
That caveat in step 3 is the entire next chapter: memo is only as good as the stability of the props crossing it.
Fix C: lift content as children (the surprising one)
Here's the fix that makes people stare. Restructure so the expensive, unrelated content is passed into the stateful component as children:
import { useState } from 'react';
function Layout({ children }) {
const [query, setQuery] = useState('');
return (
<div className="layout">
<Sidebar />
<main>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
/>
<SearchResults query={query} />
{children}
</main>
</div>
);
}
export default function Page() {
const data = [4, 9, 16, 25, 36];
return (
<Layout>
<Chart data={data} />
</Layout>
);
}
What happens, and why this works:
- You type.
setQueryfires insideLayout.Layoutre-renders. - The cascade should hit
children… but wait. Who created the<Chart data={data} />element? NotLayout.Pagecreated it, duringPage's render, and passed it down as thechildrenprop. - Elements are plain objects. When
Layoutre-renders, itschildrenprop is the exact same object in memory as last time,Pagehasn't re-rendered, so nobody created a new one. - React compares: same element object → the subtree it describes cannot have changed → bail out. React skips the entire
Chartsubtree without callingChartat all.
Jargon: "same-element bailout". When React encounters an element that is
===identical to the element from the previous render, it skips re-rendering that whole subtree. Identical description means identical outcome.
No memo wrapper. No useMemo. Just element identity doing the work, because of one fact from Part 1: elements are objects, and the same object means the same description. Composition achieved what memoization would have, for free. This is why "lift content with children" is a core performance pattern, not a trick.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Components re-render when their props change. Reality: they re-render because the parent rendered; new props are a side effect. A child with unchanged props still re-renders by default.
- Misconception: Every re-render touches the DOM. Reality: a render can produce zero DOM changes. The diff often finds nothing; the commit is then empty and the user feels nothing.
- Misconception: Wasted renders are always bad. Reality: they're only worth fixing when render cost is large (big trees, heavy computation) or frequency is high (per keystroke, per mousemove).
- Misconception:
React.memostops all re-renders. Reality: memo only blocks the parent-cascade cause. Own state and consumed context still re-render a memoized component. - Misconception: Passing
childrenre-renders them when the wrapper renders. Reality: children are elements created by the grandparent; if the grandparent didn't re-render, they're the same objects and React bails out. - Misconception: You should optimize renders everywhere, preemptively. Reality: measure with the Profiler or
console.countfirst. Most wasted renders cost nothing you'd notice.
Why it works this way
- The default cascade keeps correctness automatic. If parents didn't re-render children by default, React would have to prove every child's output is unaffected, it can't know what your closures captured. Re-rendering is the safe default; skipping is the explicit opt-in.
- Rendering is decoupled from committing precisely so wasted renders are survivable. Because render output is just data, React can render, find no changes, and throw the work away without the user ever seeing a flicker.
- Element identity enables free bailouts. Elements being immutable plain objects means
===is a complete equality check for "same UI description". The children pattern is free performance riding on a foundation from Part 1. - Colocation beats memoization because it removes work instead of guarding it. A memo boundary pays a compare cost on every parent render forever; moving state down eliminates the renders entirely.
Try it yourself
- Build the laggy
Pageexample. Putconsole.countinSidebarandChart. Type five characters. Predict, then confirm, the counts. - Apply Fix A only. Type again, confirm
Chart's counter no longer moves andPageisn't re-rendering (add a counter there too). - Apply Fix B with
datadeclared insidePageas an array literal. Confirm memo silently fails (counter still climbs). Then hoistdataoutside the component and watch the counter freeze. - Apply Fix C's
Layoutpattern. In the Profiler, record a keystroke and inspect whyChartdid not render. Then move<Chart data={data} />back insideLayout's own JSX and watch the bailout disappear.
Recap
- Three causes of a render: own state, parent render (default cascade), context change. Nothing else.
- A wasted render = identical output + zero DOM changes. Harmless unless render cost is big or frequency is high.
- Render cost is JS (cheap-ish); commit cost is DOM (what users feel). Optimize the expensive one.
- Measure first: DevTools Profiler's "why did this render", or
console.countin the body. - Fix A, colocate state into the smallest component that needs it. Renders stop entirely.
- Fix B,
React.memoskips re-renders when props are shallowly equal, but only if props are actually stable. - Fix C, pass content as
children: same element object → React skips the whole subtree, no memo needed.