Rendering and reconciliation

React · Advanced · 15 min

Knowing exactly when components re-render and when state survives explains most performance issues and 'my state reset' bugs.

Every screen update goes through three steps:

  • Trigger: the initial root.render(), or a state update in some component.
  • Render: React calls your components to compute the new JSX. When a component re-renders, by default all its descendants re-render too, whether or not their props changed.
  • Commit: React applies the minimal set of DOM changes. If the output is the same, the DOM isn't touched; rendering is not the same as repainting.

Producing the new tree and comparing it with the previous one is reconciliation. React doesn't do a general tree diff (that would be O(n³)); it uses two heuristics:

  • Elements of different types at the same position produce different subtrees. Switching <div> to <section>, or <Counter> to <Timer>, unmounts the old subtree (losing its state and DOM) and mounts a new one.
  • Among children, keys identify which element is which across renders.

This defines when state is preserved: state belongs to a position in the tree, not to the component function. Same component type at the same position keeps its state, even if you pass different props. {isAdmin ? <Profile user={a} /> : <Profile user={b} />} keeps one Profile's state for both users.

To reset state deliberately, change the key: <Profile key={user.id} user={user} /> makes each user a different component instance. That's the idiomatic way to reset a form when the selected item changes, instead of an effect.

Corollaries: never define components inside other components (new type every render), and don't flip a subtree's wrapper element type unless you want a remount. Because re-rendering cascades, pushing state down to the component that needs it, or passing static JSX as children, keeps renders small even before any memoization.

Example

import { useState } from "react";

function Counter({ label }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{label}: {count}</button>;
}

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {/* Same type, same position: the count is SHARED across players */}
      {isPlayerA ? <Counter label="Taylor" /> : <Counter label="Sarah" />}

      {/* Different keys: each player gets their own fresh state */}
      {isPlayerA ? <Counter key="taylor" label="Taylor" /> : <Counter key="sarah" label="Sarah" />}

      <button onClick={() => setIsPlayerA(p => !p)}>Next player</button>
    </div>
  );
}

Remember

  • Updates go trigger, render, commit; re-rendering a parent re-renders its children by default
  • Same type at the same position preserves state; a different type or key remounts
  • Change a component's key to reset its state on purpose

Watch out for

  • Wrapping content in a different element conditionally (<div> vs <span>) and unexpectedly losing child state.

Check yourself

  1. {edit ? <Form initial={a} /> : <Form initial={b} />} - the user types into the form, then edit flips. What happens to the typed text?
  2. When a parent component re-renders, what happens to a child whose props did not change (no memo)?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/learn/preserving-and-resetting-state