Under the Hood
Reactinternals

Re-renders, memoization, and performance

A component re-renders when its own state changes or its parent re-renders, so updates cascade down a tree by default — but a re-render is not the same thing as a DOM mutation, and knowing exactly what triggers one plus the handful of memoization tools that prune the cascade is what closes out this module.

Re-renders, memoization, and performance

Every earlier lesson in this module answered a "how" question — how the tree is described (virtual DOM), how it's diffed, what data structure it's diffed over, when the DOM actually changes, where state lives, when work gets to run. This lesson answers the question that actually matters for a real app's performance: what causes a re-render in the first place, and what do you do about the ones you don't need? The rule that decides it is short enough to state up front — a component re-renders when its own state changes, or when its parent re-renders — and almost everything else in this lesson is consequences of that one rule.

What actually triggers a re-render

A function component re-renders for exactly two reasons:

  1. Its own state or context changes — a useState setter's queued update gets applied (lesson 6), or a useContext value it reads changes.
  2. Its parent re-renders — and this is the one that surprises people: by default, when a component re-renders, React re-runs every child component in its returned tree, regardless of whether the props passed to those children actually changed.

That second rule is why re-renders cascade. A single setState call high in the component tree doesn't just re-run the component that called it — it re-runs that component's entire subtree, top to bottom, because each child in the returned tree is itself a component whose "parent re-rendered" box just got checked.

function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <ExpensivePanel /> {/* re-renders every time count changes, */}
      <Sidebar />        {/* even though neither reads count at all */}
    </div>
  );
}

Clicking the button re-renders App, and by the default-cascade rule, also re-renders ExpensivePanel and Sidebar — neither of which reads count, neither of which has any reason, from a "did my inputs change" standpoint, to run again. This is not a bug; it's how React chose to trade off correctness for simplicity — always re-rendering children is trivially correct (nothing can go stale), at the cost of doing work that's frequently unnecessary.

A re-render is not a DOM mutation — say this twice

The single most important framing in this lesson: "re-render" means "run the component function again and reconcile its returned tree" — it does not mean "change the DOM." ExpensivePanel re-rendering means React calls the ExpensivePanel function, gets back a new element tree, and diffs it (the diffing algorithm) against what it returned last time. If that diff finds no differences — same types, same props, same text — reconciliation commits nothing for that subtree. The cost of an unnecessary re-render is calling a function and doing a cheap tree diff, not touching the DOM. That's usually fast. This is exactly why the fear of re-renders is frequently overblown: most re-renders, even "unnecessary" ones, are inexpensive, because the diff-then-commit split from lesson 1 was built precisely to make a no-op re-render cheap.

The re-renders worth caring about are the ones where re-running the function is itself expensive — heavy computation inside the component body, or a large enough subtree that even a no-op diff adds up across hundreds of nodes. That's the case memoization is for.

React.memo: skip re-rendering a component when its props haven't changed

React.memo wraps a component so that, when its parent re-renders, React compares the new props against the last-rendered props with a shallow equality check — and if every prop is shallow-equal (=== for each key), skips re-rendering that component and its subtree entirely, reusing the previous render's output.

const ExpensivePanel = React.memo(function ExpensivePanel({ config }) {
  // heavy work in here
  return /* ... */;
});

Wrapped this way, ExpensivePanel re-renders only when config actually changes by shallow comparison — App re-rendering because of an unrelated count update no longer cascades into it. This is the tool that cuts the cascade at a specific node in the tree.

useMemo: cache an expensive computed value

useMemo(fn, deps) doesn't stop a component from re-rendering — it caches the result of an expensive calculation inside a component that's already re-rendering, so the calculation itself doesn't repeat unless its dependencies changed (lesson 6 covered the hook record this is stored in):

function ProductList({ products, query }) {
  const filtered = useMemo(
    () => products.filter(p => p.name.includes(query)),
    [products, query] // recompute only when these change
  );
  return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Every render of ProductList still runs the function body — useMemo just skips re-running the filter if products and query are the same references as last time, returning the previously computed array instead.

useCallback: cache a function's identity

useCallback(fn, deps) is the same caching idea specialized to function values: it returns the same function reference across renders as long as deps haven't changed, instead of the brand-new function your component body would otherwise create every single render.

function App() {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    doSomething(count);
  }, [count]);

  return <ExpensiveChild onClick={handleClick} />;
}

Why useCallback/useMemo exist: the referential-identity problem

This is the piece that ties React.memo back to useMemo/useCallback, and the part that trips people up the most. React.memo's shallow-equality check compares object, array, and function props with ===. But an object, array, or function literal created during render is a brand-new value every render, even when its contents are identical to last time:

function App() {
  const [count, setCount] = useState(0);
  return (
    // Every render creates a NEW function and a NEW object —
    // ExpensiveChild's memo comparison sees "changed props" every time,
    // and re-renders anyway, no matter how expensive it was to memoize it.
    <ExpensiveChild
      onClick={() => doSomething(count)}
      style={{ color: 'red' }}
    />
  );
}

{ color: 'red' } is equal in content every render, but it's a different object in memory every render — === says "different," so React.memo on ExpensiveChild sees changed props and re-renders regardless of the memoization. This is exactly why React.memo'd children are paired with useCallback (for function props) and useMemo (for object/array props) at the call site: the memoization has to happen where the value is created, not just where it's consumed, or the referential-identity mismatch defeats the whole point.

Keys, again: identity across renders

The diffing lesson covered key as the signal React uses to match list items across a diff. It belongs in this lesson too, from the re-render angle: a stable key (an id, not an array index that shifts) is what lets React recognize "this is the same logical item as last render" and reuse its component instance, its fiber, and its hook state — rather than tearing it down and remounting a fresh one. A missing or unstable key doesn't just cause diff bugs; it can silently defeat memoization on list items, because React concludes it's looking at a different component instance entirely.

The honest caveat: measure before you memoize

Synthesis: the whole module, one loop

Every lesson in this module has been one loop, viewed from a different angle:

  1. The virtual DOM — describe the UI as data instead of mutating the DOM by hand; React diffs descriptions and applies only the difference.
  2. Elements, components, and JSX — what that description actually is: plain objects, produced by component functions, that JSX compiles down to.
  3. The diffing algorithm — the heuristics (same type, keys) that make comparing two trees cheap instead of combinatorially expensive.
  4. Fiber — the linked data structure that lets the diff-and-build walk pause and resume, instead of running as one uninterruptible recursive call.
  5. Render and commit phases — the split between computing what changed (interruptible) and applying it to the real DOM (atomic, uninterruptible).
  6. Hooks — state that survives across a component's own repeated renders, stored as a linked list of records hanging off the fiber, matched by call order.
  7. Scheduling and concurrent React — fiber's interruptibility used deliberately, to yield to the browser and prioritize urgent updates over expensive ones.
  8. This lesson — exactly what causes a render to happen at all, and the tools (React.memo, useMemo, useCallback) that prune a cascade down to the renders that actually matter.

Every performance habit in this lesson exploits a mechanism from an earlier one: React.memo only works because reconciliation already diffs by component identity (lesson 3/4); useMemo/useCallback only matter because renders re-run function bodies from scratch every time (the core model from lesson 1); profiling before memoizing only makes sense because most re-renders are cheap precisely because commit only touches what actually differs (lesson 5).

And the module's two closing ties reach past React entirely, back to the platform underneath it. The scheduler from lesson 7 exists because React runs on the same single main thread the event loop module describes — React yields to that loop instead of blocking it, the identical discipline in a different layer of the stack. And every DOM mutation React ever commits — the actual output of everything this module has covered — goes through the browser's own rendering engine, the reflow-and-repaint pipeline neither React nor the virtual DOM ever replaces, only feeds.

Where the curriculum goes from here

This closes the React internals module, but the shape underneath it — a declarative description, diffed cheaply, applied to a stateful runtime, scheduled so it never blocks the one thread everything shares — is the same shape this entire site has been building toward, module after module. Carry the model forward: reconciliation is a specific answer to a general problem (how much recomputation can you afford after a change), fiber is a specific answer to another (how do you make a big traversal interruptible), and the scheduler's yield is the same "protect the one thread" imperative you've now seen argued for from the event loop's side and from React's side both. That's the whole point of an "under the hood" curriculum — the same handful of hard constraints, showing up again and again, in different clothes.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. State the two reasons a function component re-renders. Which one explains why updates cascade down a tree?
  2. Why is 'this component re-rendered' not the same claim as 'the DOM changed'? What has to happen in between for a DOM mutation to actually occur?
  3. What does React.memo actually compare, and what does it do differently when that comparison passes versus fails?
  4. Explain the referential-identity problem: why does passing an inline object or arrow function as a prop defeat React.memo even when the values are logically the same every render?
  5. What is the difference between what useMemo caches and what useCallback caches?
  6. Why is 'wrap everything in React.memo and useMemo' an anti-pattern rather than a safe default? What does memoization cost?
  7. Walk through this module's eight lessons in one sentence each, and name the one mechanism from an earlier lesson each later lesson's performance habit depends on.