Render & commit

Published:

The state path looks simple:

  1. an event handler calls a state setter
  2. React renders again
  3. React updates the DOM

But the last two steps hide a jump. Calling a component produces React elements, while updating the page requires DOM mutation. Those are not the same kind of operation.

That leaves the next hole:

if React first computes a UI description and then changes the DOM, where is the boundary between pure rendering and host-side mutation?

The smallest description #

We already know that JSX creates React elements. Perhaps creating a component element is enough to make the component run and put its result on the page.

We can place logs on both sides of root.render(...) to test that idea:

function Greeting({ name }) {
  console.log('Greeting rendered');
  return <h2>Hello, {name}</h2>;
}

const element = <Greeting name="Maya" />;

console.log('element type:', element.type.name);
console.log('before root.render:', mountNode.textContent);

root.render(element);

setTimeout(() => {
  console.log('after React finishes:', mountNode.textContent);
}, 20);

Waiting to run

Not run yet.

The first two logs expose the failed assumption:

  • the JSX expression creates an element whose type is Greeting
  • Greeting has not run yet
  • the mount node is still empty

Only after root.render(element) does React call Greeting, follow the <h2> it returns, and eventually put Hello, Maya into the mount node.

So creating the description does nothing to the DOM. Giving the description to the root triggers some later work. But that one request still hides at least two things: React calls Greeting, and the DOM changes.

The next question is:

when state changes, can we observe the component calculation separately from the visible update?

Triggering the work #

The initial request was explicit:

const root = createRoot(container);
root.render(<App />);

createRoot(...) creates a React root for a browser DOM node, and root.render(...) asks React to display a React node inside it.[4]

After the first result is on the page, a state setter gives us a second way to request work. Add a log at the top of the component and click once:

const { useState } = React;

function Counter() {
  const [count, setCount] = useState(0);
  console.log('render:', count);

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

root.render(<Counter />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

The log appears once for the initial request and once after the click. We have found two triggers:

  1. root.render(<Counter />) requests the initial render.
  2. setCount(count + 1) requests a later render with updated state.

The setter does not call Counter as an ordinary nested function. It requests work from React. React later calls Counter with the next state value.

That gives us the first part of the path:

  1. root.render(...) or a state update triggers work
  2. React calls a component with its current inputs
  3. the component returns another description

We still have not explained how far React follows that description or where the DOM change begins.

Rendering the tree #

Counter returned a host element directly. What happens when one component returns another component instead?

Put a log in every component in a small tree:

function Label({ children }) {
  console.log('render Label');
  return <strong>{children}</strong>;
}

function Card() {
  console.log('render Card');
  return (
    <section>
      <Label>Status</Label>
      <p>Ready</p>
    </section>
  );
}

function App() {
  console.log('render App');
  return <Card />;
}

root.render(<App />);

Waiting to run

Not run yet.

The logs uncover a recursive calculation. App returns an element whose type is Card, so React calls Card. Card returns an element containing Label, so React calls Label.

This continues until the descriptions contain host types such as 'section', 'strong', and 'p'. At that point React DOM has enough information to know what the browser tree should contain.

We can now describe one kind of work without mentioning DOM mutation:

React calls components recursively to calculate a tree of host descriptions

But perhaps React changes the DOM during each of those component calls. We need a smaller probe at the moment state changes.

The DOM stays old #

Read the button text immediately before and after its state setter. Then read it again after React has had an opportunity to process the request:

const { useState } = React;

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

  function handleClick(event) {
    const button = event.currentTarget;
    console.log('before setter:', button.textContent);
    setCount(count + 1);
    console.log('after setter:', button.textContent);

    setTimeout(() => {
      console.log('after commit:', button.textContent);
    }, 0);
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

root.render(<Counter />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

The crucial result is the second log. Immediately after setCount(...), the existing button still says Count: 0. The setter did not directly rewrite the DOM node. After the handler finishes, React calls Counter with the next state and the button eventually says Count: 1.[3]

Now we have evidence for two separate stages:

  1. React calls components to calculate the next host tree.
  2. React DOM mutates the real DOM to match that result.

React calls the first stage render and the second stage commit.[1]

During the initial commit, React DOM inserts prepared nodes into the page. During later commits, it can change text or properties, insert nodes, or remove nodes. The state setter triggers the process; it is not itself the DOM mutation.

Several state updates can contribute to one next render before React commits the result. This prevents an event handler from exposing a half-updated interface. The exact scheduling details can vary, so setTimeout(...) is not an application pattern for waiting on React. It only makes the old and committed DOM values visible in this demo.

Pure rendering #

The new boundary creates another tempting attempt. If React calls our component on every update, perhaps the component can record useful information while it runs.

Start with an array outside the component and append one visitor during each render:

const { useState } = React;
const visited = [];

function VisitorList() {
  const [refreshes, setRefreshes] = useState(0);

  visited.push('Maya');

  return (
    <div>
      <button onClick={() => setRefreshes(refreshes + 1)}>
        Render again
      </button>
      <p>Visitors: {visited.join(', ')}</p>
    </div>
  );
}

root.render(<VisitorList />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

We only described one visitor, Maya. After clicking, the page contains her twice. The state value exists only to request another render, but every calculation also changes visited, a value created outside the component.

The failure gives us the constraint:

the result must depend on the component inputs, how many times React happened to calculate it is irrelevant

React may render again, restart work, or call rendering logic extra times in development. If rendering mutates an external value, repeating the calculation repeats an unintended action.[a]

Move the array inside the component so it belongs to one calculation:

const { useState } = React;

function VisitorList() {
  const [refreshes, setRefreshes] = useState(0);
  const visited = ['Maya'];

  return (
    <div>
      <button onClick={() => setRefreshes(refreshes + 1)}>
        Render again
      </button>
      <p>Visitors: {visited.join(', ')}</p>
    </div>
  );
}

root.render(<VisitorList />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

Now another render still describes one visitor. Creating and changing local values is safe because those values belong to that calculation.

We can finally state the rule forced by the failed external array:

rendering must be a pure calculation

For the same props, state, and context, a component should produce the same description and should not change values that existed before render.[2] The boundary is crossed when render changes a DOM node, a global value, a network connection, or another external system.

Event handlers are already one legitimate place for side effects because they run in response to a specific interaction, outside render.

Rebuilding the DOM #

We have separated calculation from mutation, but our model is still wasteful. React could throw away the old DOM tree after every render and build a new one.

An uncontrolled input gives us a way to test that idea. Type a value directly into its DOM-owned state, trigger a component render, and compare the node before and after:

const { useState } = React;

function Profile() {
  const [renders, setRenders] = useState(0);
  console.log('render Profile:', renders);

  return (
    <div>
      <input aria-label="Draft" />
      <button onClick={() => setRenders(renders + 1)}>
        Render again: {renders}
      </button>
    </div>
  );
}

root.render(<Profile />);

setTimeout(() => {
  const input = mountNode.querySelector('input');
  input.value = 'unfinished note';
  mountNode.querySelector('button').click();

  setTimeout(() => {
    console.log('input value:', input.value);
    console.log('same input node:', input === mountNode.querySelector('input'));
  }, 0);
}, 20);

Waiting to run

Not run yet.

Profile definitely renders again: the log appears and the button text changes. But the input value survives, and the final log says that it is the same DOM node.

So the rebuild model fails. A component render does not imply replacement of every corresponding DOM node. React can commit the changed button text while leaving the input untouched.

The boundary is now more precise:

  • a render is a component calculation
  • a commit is a set of host mutations
  • one render can lead to several DOM mutations, one mutation, or no mutation to a particular node

That opens the next hole rather than closing it here:

how does React decide which old host nodes correspond to which new descriptions, and which mutations are necessary?

That is the reconciliation problem.

Paint after commit #

One last boundary remains. Even after React changes the DOM, React does not place pixels on the display itself. The browser performs layout and paint from the updated document.

Browser rendering therefore has a different meaning from React rendering. Render means the component calculation; paint means the browser displaying the committed page.

The full path is therefore:

  1. an initial root render or state update triggers work
  2. React calls components to calculate the next element tree
  3. React DOM commits the necessary mutations to the DOM
  4. the browser paints the updated page

Filling the hole #

The experiments forced the boundary into view:

  • creating JSX did not run the component or change the DOM
  • giving the element to the root caused React to call components recursively
  • calling a state setter left the DOM unchanged inside the handler
  • the DOM changed only after React calculated the next result
  • mutating an external array during that calculation made repeated renders corrupt the result
  • rendering again did not replace an unchanged input node

The first kind of work is render: React calls component functions and calculates the host tree that should exist. The second is commit: the renderer applies changes to the host environment. For React DOM, that environment is the browser DOM.

The separation gives component code a strong rule: rendering describes the desired result and must remain pure. React, not the component, owns the DOM mutations that make that result real.

Final definition #

Rendering is React's calculation of the next UI description by calling components with their current props, state, and context. Committing is the renderer's application of the necessary host-side mutations to make the displayed UI match that description. A state update triggers this process, but it does not mutate the DOM directly. A render can happen without changing a particular DOM node, and the browser paints only after React has committed DOM changes.

Summary #

Render and commit fill the hole between state updates and visible DOM updates:

  • root.render(...) requests the initial work
  • a state setter can request a later render
  • rendering calls components and recursively calculates the next tree
  • rendering must be pure because React controls when and how often that calculation runs
  • committing performs the necessary DOM mutations
  • a component render does not imply that every corresponding DOM node changes
  • the browser paints after the DOM commit
  • deciding which host changes are necessary is the next reconciliation problem

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. Development tools and Strict Mode may call rendering logic more than once to expose mistakes. Components must therefore be correct whenever React calls them, not depend on an exact call count. · Back

References

  1. React: Render and Commit (opens in a new tab) · Back
  2. React: Components and Hooks must be pure (opens in a new tab) · Back
  3. React: Queueing a Series of State Updates (opens in a new tab) · Back
  4. React DOM `createRoot` API reference (opens in a new tab) · Back