The idea: render a new tree, diff it, patch the DOM

Changing the real DOM is slow compared with changing plain objects. So React does not rebuild the page when the state changes. It calls your component's render() again, which returns a virtual tree: plain objects that describe what the page should look like. React then compares the new tree with the one from the last render. This compare step is called reconciliation or the diff. It ends with a short list of patches, and only those patches touch the real DOM.

setState changes the state; render() builds a new virtual tree; the diff compares it with the old virtual tree and writes a list of patches such as textContent and insertBefore; the commit applies the patches to the real DOM. The new tree becomes the old tree for the next render.
Render and diff work on plain objects; only the commit touches the real DOM, one call per patch.

The page runs one small component, a shopping list: a heading, a card, and a <ul> with one <li> per item. Each <li> has an <input>, and the user has typed a quantity into each one (Milk 2, Eggs 12, Bread 1). Those values live only in the real DOM; the virtual tree does not know them. That makes it easy to see which DOM nodes React kept and which it threw away. The counter at the top counts the DOM calls of the last update.

Same type: patch in place

The diff starts at the root and walks both trees together. When the old and new node have the same type (both <h1>), React keeps the DOM node. It compares the props and the text, and writes a patch only for what changed. In Demo 1: text change, 1 patch the title goes from "Shopping list" to "Groceries". Every other node is the same, so the whole update is one call: h1.textContent = "Groceries". The dark theme change is also one call, div#app.setAttribute("class", "dark").

Different type: unmount and mount

When the type differs, React does not try to compare the two subtrees. It assumes they are unrelated, unmounts the old subtree and mounts a new one. In Demo 2: div → span remounts the list only the card's tag changes, but React removes the old <div class="card"> with everything in it and inserts a freshly built <span class="card">: 2 DOM calls, 8 new elements. The new inputs are empty. Anything kept in the old nodes is lost: typed text, focus, scroll position, and the React state of components inside the subtree.

This rule is what makes the diff fast. A general tree diff costs O(n³); React's rules (same type → compare, different type → replace, list children by key) make it O(n), one pass over the tree.

Lists: matching children by key

For a list of children React has to decide which old child goes with which new child. It uses the key. A child without a key gets its index as the key. In Demo 3: insert at the head, no keys, Butter is added at the start. Matched by position, old row 0 (Milk) is compared with new row 0 (Butter), old row 1 (Eggs) with new row 1 (Milk), and so on. Every row's text differs, so every row is patched: 3 textContent calls and an insertBefore for the last row, 4 DOM calls. The inputs did not move, so Butter now shows Milk's quantity.

In Demo 4: insert at the head, with keys, each <li> has key={item.id}. Milk is matched with Milk, Eggs with Eggs, Bread with Bread. Butter's key is new, so the whole update is one ul.insertBefore(new li "Butter", li "Milk"), and every value stays next to its item.

Left, no keys: the old rows Milk, Eggs, Bread are matched by position with the new rows Butter, Milk, Eggs, Bread; three rows get a textContent patch and the fourth is inserted, 4 DOM calls. Right, key equals item id: lines join a to a, b to b, c to c; only d (Butter) is new, 1 insertBefore.
Matched by position, an insert at the head changes every row; matched by key, it is one insertBefore.

To find moves, React walks the new children in order and remembers lastPlacedIndex, the largest old index it has kept in place so far:

lastPlacedIndex = 0
for each new child, in order:
    old = oldChildrenByKey[child.key]          // index if there is no key
    if old is missing or has another type:  build it, mark it "insert"
    else if old.index < lastPlacedIndex:    reuse it, mark it "move"
    else:                                    reuse it, lastPlacedIndex = old.index
old children whose key is gone:              removeChild
commit: each marked child -> parent.insertBefore(node, next unmarked sibling or null)

Index as key: the input bug

Using the index as the key looks like a fix, but it is the same as no key: the keys 0, 1, 2 always match by position. Demo 5: reorder with index as key (bug) reverses the list. React sees key 0 with "Milk" in the old tree and key 0 with "Bread" in the new tree, so it keeps the node and patches its text: 2 textContent calls. The text moves, the inputs do not. Bread now shows 2, which was Milk's quantity, and Milk shows 1.

With key={item.id} (pick key = item.id and reverse items), React moves the DOM nodes instead: Bread stays, Eggs and Milk each get an insertBefore to the end, and the values move with their rows. Use the index as the key only for a list that never reorders, inserts or deletes in the middle, and whose rows have no state of their own.

change (3 items)no key / key = indexkey = item.id
title text1 textContent1 textContent
dark theme1 setAttribute1 setAttribute
add item at head4: 3 textContent + 1 insertBefore, ✗ values shifted1 insertBefore, ✓ values kept
reverse items2 textContent, ✗ values stay put2 insertBefore (moves), ✓ values kept
card div → span2: removeChild + insertBefore, ✗ values lost2: removeChild + insertBefore, ✗ values lost

Render phase and commit phase

React splits the work in two. In the render phase it calls render() and diffs; it may also build new DOM nodes off the page (createElement). Nothing visible changes, so React can pause this phase, or throw it away if a newer update arrives. In the commit phase it applies the patches to the real DOM in one go, without stopping, so the user never sees a half-updated page. The page shows the same split: the patch list fills up during the diff and is applied afterwards, one patch per DOM call.

See also the Observer pattern vs publish–subscribe: setState is how a component tells React that its state changed, much like a subject notifying its observers. And see the Node.js event loop: in the browser, state updates from one event handler are batched and the commit runs before the browser paints.

See also Server-side vs Client-side Rendering: the same React tree rendered to HTML on the server and then hydrated, or rendered in the browser from JSON.

What the page leaves out

Fiber nodes and the work loop, priorities and lanes, time slicing and concurrent rendering, batching of several setState calls, effects (useEffect, refs) that run after the commit, components inside components (the page has one), event handlers on DOM nodes, controlled inputs (whose value lives in state, which avoids the input bug), text nodes as separate children, other frameworks (Vue and Preact diff lists with a two-ended scan; Svelte and Solid compile updates and need no virtual DOM), and how React reuses DOM nodes during hydration.