Skip to main content

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 work
const changes = diff(oldTree, newTree); // more cheap JS work
commit(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:

  1. onChange fires, calling setQuery('a').
  2. State lives in Page, so Page re-renders (cause #1).
  3. The render cascade fires: Sidebar, SearchResults, and Chart all re-render (cause #2), even though Sidebar and Chart received identical props.
  4. console.count shows Chart rendered: 2. The expensive function ran again. Sidebar rebuilt its elements too.
  5. React diffs everything, finds only the input's value and 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:

  1. You type; setQuery runs, but the state lives in SearchBox now.
  2. SearchBox re-renders (cause #1). Its subtree is just the input and a <p>.
  3. Page does not re-render at all. No parent rendered it, its state didn't change, it consumes no context.
  4. Chart and Sidebar never 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:

  1. You type; Page re-renders (state lives here again in this variant).
  2. The cascade reaches <Chart data={data} />.
  3. React sees Chart is memoized and compares props: is data the same as last render? data is re-created as a fresh array literal in Page… careful! In this snippet data is a new array each render, so the shallow compare fails and Chart still renders. To make memo stick, hoist it: const data = [4, 9, 16, 25, 36]; outside the component, or wrap it in useMemo.
  4. With data stable, the compare passes, React skips calling Chart entirely 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:

  1. You type. setQuery fires inside Layout. Layout re-renders.
  2. The cascade should hit children… but wait. Who created the <Chart data={data} /> element? Not Layout. Page created it, during Page's render, and passed it down as the children prop.
  3. Elements are plain objects. When Layout re-renders, its children prop is the exact same object in memory as last time, Page hasn't re-rendered, so nobody created a new one.
  4. React compares: same element object → the subtree it describes cannot have changed → bail out. React skips the entire Chart subtree without calling Chart at 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

Rendered diagram (PNG hi-res):

rendered diagram

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.memo stops all re-renders. Reality: memo only blocks the parent-cascade cause. Own state and consumed context still re-render a memoized component.
  • Misconception: Passing children re-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.count first. 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

  1. Build the laggy Page example. Put console.count in Sidebar and Chart. Type five characters. Predict, then confirm, the counts.
  2. Apply Fix A only. Type again, confirm Chart's counter no longer moves and Page isn't re-rendering (add a counter there too).
  3. Apply Fix B with data declared inside Page as an array literal. Confirm memo silently fails (counter still climbs). Then hoist data outside the component and watch the counter freeze.
  4. Apply Fix C's Layout pattern. In the Profiler, record a keystroke and inspect why Chart did not render. Then move <Chart data={data} /> back inside Layout'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.count in the body.
  • Fix A, colocate state into the smallest component that needs it. Renders stop entirely.
  • Fix B, React.memo skips 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.

Next

React.memo →