Skip to content
~/amoghshendre
← back to blog

React re-renders: the mental model that finally made it click

3 min read
  • react
  • javascript

For years I treated React performance as a set of rules to memorize. Wrap it in memo. Wrap the callback in useCallback. Wrap the object in useMemo. It worked often enough that I never questioned the model underneath — which meant I also couldn't tell when the rules were doing nothing at all.

The thing that fixed it was giving up one assumption: a render is not a DOM update.

What a render actually is

A render is React calling your component function to ask what the UI should look like. That's it. It returns a description — an object tree — and React compares it against the previous one. Only the differences touch the DOM.

Calling a function is cheap. Touching the DOM is not.

So "my component re-rendered" is not a performance problem. It's the machine working. The problem is only ever one of two things: the render itself does expensive work, or the render tree being walked is far larger than it needs to be.

The default rule

When a component re-renders, React re-renders all of its children. Not because their props changed — because their parent rendered.

function App() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <ExpensiveTree />   {/* re-renders on every click */}
    </div>
  );
}

ExpensiveTree takes no props at all. It still re-renders on every click, because App re-rendered and it lives inside App's output.

Why memo so often does nothing

The reflex here is memo(ExpensiveTree). Sometimes that works. Often it silently doesn't, and the reason is that memo compares props by reference:

function App() {
  const [count, setCount] = useState(0);
  const config = { theme: "dark" };   // new object, every render
 
  return <MemoizedChild config={config} />;  // memo never hits
}

{ theme: "dark" } is a fresh object each render. memo compares it to the previous one, sees a different reference, and re-renders anyway. You've added a comparison and gained nothing.

That's the trap: memo doesn't fail loudly. It just quietly stops paying off, and the code keeps the ceremony.

Moving state down instead

Before reaching for memoization, ask where the state actually needs to live. Usually the answer is "lower than I put it."

function App() {
  return (
    <div>
      <Counter />        {/* owns its own state now */}
      <ExpensiveTree />  {/* outside it — never re-renders */}
    </div>
  );
}
 
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

No memo, no useCallback, no dependency arrays to keep correct. ExpensiveTree doesn't re-render because it is no longer inside the component that changes.

The same idea in reverse — passing children through — is why this works too:

function Shell({ children }) {
  const [open, setOpen] = useState(false);
  return <aside data-open={open}>{children}</aside>;
}

children was created by the parent's render, not Shell's. When open changes, Shell re-renders but children is the same element object as before, so React skips that subtree.

The model, in one paragraph

Rendering is calling a function to get a description of the UI. It's cheap by default. State changes re-render the component that owns the state and everything beneath it, so the leverage is in where state lives, not in how many memo wrappers you stack around the consequences. Reach for memo when you've measured a genuinely expensive subtree and cannot move the state — not as a reflex.

Once that clicked, I stopped memorizing rules. Most of them turned out to be the same rule.

related