React’s reconciliation process is often described as a "diffing" algorithm, but that’s a misleading oversimplification; it’s more accurately a synchronization process that happens in distinct, ordered phases.

Let’s watch React update the DOM. Imagine this simple component:

function Counter() {
  const [count, setCount] = React.useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

When the "Increment" button is clicked, the onClick handler calls setCount(1). This triggers a re-render.

The Render Phase (The "What to Change" Phase)

This is where React figures out what the UI should look like after the state update. It’s a purely computational, non-mutating phase. React calls your component functions (or render methods for class components) with the new props and state. It then compares the new JSX output with the previous one. This comparison is the "diffing" part.

React doesn’t actually change the DOM here. Instead, it builds a new "virtual DOM" tree (or more accurately, a new fiber tree representing the desired UI) and compares it to the existing fiber tree. For our Counter component, React sees that the count value inside the <p> tag needs to change from 0 to 1. It identifies the specific DOM node that needs updating.

This phase can be interrupted. If a higher-priority update comes in (like a user typing), React can pause the current render, handle the higher-priority update, and then resume the interrupted render. This is crucial for keeping the UI responsive.

The Commit Phase (The "Actually Change It" Phase)

Once the Render phase is complete and React has a clear picture of all the DOM changes needed, it enters the Commit phase. This is where React actually mutates the DOM. It applies the calculated changes to the browser’s DOM tree.

In our Counter example, React will find the <p> element that displays "Count: 0" and update its text content to "Count: 1". It will also update the button’s event listener if necessary (though in this simple case, it likely won’t need to). This phase is synchronous and cannot be interrupted. React wants to get the DOM updates done as quickly as possible to minimize perceived lag.

This phase also includes other side effects, like calling componentDidMount and componentDidUpdate in class components, or running the effect functions returned from useEffect hooks. These side effects are batched and applied after the DOM is updated.

The Paint Phase (The Browser’s Job)

This is the phase the browser handles. After React commits the DOM changes, the browser takes over. It needs to:

  1. Layout/Reflow: Calculate the positions and dimensions of all elements on the page. If a change affects layout (e.g., changing an element’s width), the browser might need to recalculate the layout for a significant portion of the page.
  2. Paint: Fill in the pixels for each element. This involves drawing text, colors, borders, shadows, etc.
  3. Composite: Combine the various painted layers into the final image displayed on the screen.

React tries to minimize the work the browser has to do, especially during layout, by batching DOM mutations and avoiding unnecessary re-renders.

The "What If" Scenario: State Updates and Re-renders

Consider this:

function App() {
  const [value, setValue] = React.useState('');
  const [otherState, setOtherState] = React.useState(0);

  const handleChange = (event) => {
    setValue(event.target.value); // This triggers a re-render
  };

  React.useEffect(() => {
    console.log('Effect ran!');
  }, [value]); // This effect depends on 'value'

  return (
    <div>
      <input type="text" value={value} onChange={handleChange} />
      <p>Current Value: {value}</p>
      <button onClick={() => setOtherState(otherState + 1)}>
        Increment Other State ({otherState})
      </button>
    </div>
  );
}

When you type into the input field, setValue is called. This initiates a Render phase. React compares the new virtual DOM with the old. It sees the input’s value prop needs to change, and the p tag’s content needs to change. The otherState hasn’t changed, so the Increment Other State button and its associated logic are largely ignored in this render pass.

After the Render phase, React enters the Commit phase. It updates the value attribute of the input element and the text content of the paragraph. Because value has changed, the useEffect hook’s dependency array now has a new value. After the DOM is updated, React schedules the useEffect callback to run.

If you click the "Increment Other State" button, setOtherState is called. This also triggers a Render phase. React compares the new virtual DOM. It sees that otherState has changed, so the p tag within the button needs updating. The value state and the input field haven’t changed, so React efficiently skips re-rendering those parts of the tree. The Commit phase then updates the button’s text. The useEffect callback does not run because its dependency (value) hasn’t changed.

The real magic is in how React batches updates. If you were to call setValue and setOtherState in rapid succession within the same event handler, React might batch these into a single re-render cycle to the DOM. However, due to the nature of onChange firing on every keystroke, setValue will trigger many individual re-renders.

The surprising thing is that React’s "virtual DOM" is not actually a DOM at all, but a tree of JavaScript objects called "Fibers." These Fibers represent the work React needs to do, including which components to render, what DOM nodes to create or update, and what side effects to run. The reconciliation process is essentially a traversal and manipulation of this Fiber tree.

The next frontier is understanding how React Suspense and concurrent rendering leverage these phases to create more resilient and performant UIs.

Want structured learning?

Take the full React course →