What you'll learn: By the end of this lesson you will be able to explain what a React element is and what people mean by the "virtual DOM", describe how React compares the tree it rendered last time with the tree it rendered now (reconciliation), predict when a DOM node and a component's state are kept and when they are thrown away, choose correct keys for lists, and recognise the three bugs that come from getting identity wrong: lost input, shifted state, and remounting on every render.
Think of a newspaper editor who receives a revised page. She does not retype it. She lays the new proof next to the old one, finds what changed, and tells the print room "replace this headline, leave the rest". That only works because the two proofs are laid out the same way. If the layout changes completely, she has nothing to compare and the page is reset.
React works the same way after a state change. It holds a description of the screen from the last render and gets a new one from this render. Before it touches the browser's DOM, it works out how the two relate. That comparison is reconciliation, and the description is what many people call the virtual DOM. The comparison decides more than which DOM nodes change: it decides which components keep their state.
The running example is a product comparison page: a list of products as rows, each with a name, a price and a quantity input, and a "Sort by price" button. A week after launch, users report that the quantity they typed "jumps to the wrong product" after sorting. That bug has one cause, and this lesson explains it.
The core idea
Four statements carry the lesson.
- A React element is a plain description of UI, not a DOM node. Creating one does not render anything and does not touch the DOM.
- Rendering produces a new tree of elements. On an update, React calls your components again and gets a new description of the subtree below the changed component.
- Reconciliation compares the old and new descriptions, position by position, using two rules of thumb. Elements of different types produce different trees, and a
keyhints which children are the same across renders. - Identity decides what survives. Where React decides "same thing as before", it keeps the DOM node and the component's state and passes new props. Where it decides "different thing", it discards the old one and creates a new one, state included.
The documentation puts the rendering part plainly: "Rendering is React calling your components." In the commit step React applies "the minimal necessary operations" to make the DOM match. This lesson covers the comparison in between. The order of calls, effects and the commit is the next lesson, React's Render and Commit Phases.
Version note: react.dev lists React 19.3 as the latest version at the time of writing; the lab ran 19.3.1. The old reconciliation page is for React 18.2.
Elements: descriptions, not DOM nodes
A React element is a small object that says what you want on screen. JSX is syntax for creating one. This code and the JSX <div className="card">...</div> build the same kind of object:
import { createElement } from "react";
function Greeting({ name }) {
return createElement("h1", null, `Hello, ${name}`);
}
const tree = createElement(
"div",
{ className: "card" },
createElement(Greeting, { name: "Ada" }),
createElement("p", { key: "note" }, "Ready"),
);
console.log(tree.type);
console.log(tree.props.className);
const [greeting, note] = tree.props.children;
console.log(greeting.type === Greeting, greeting.props);
console.log(note.type, note.key, note.props);
Output (React 19.3.1, run in Node):
div
card
true { name: 'Ada' }
p note { children: 'Ready' }
Notice four things. The type is a string ("div", a DOM element) or a function (Greeting, a component). The attributes are in props. The key is not in props: the documentation says key and ref are not available as element.props.key but on the element itself. And nothing here created a DOM node: creating an element "does not render the Greeting component or create any DOM elements". Calling Greeting is the job of React's render step.
Elements are meant to be thrown away: the documentation says to treat them and their props as immutable, and every render creates a new set.
What "virtual DOM" means
The old React documentation defines it: "The virtual DOM (VDOM) is a programming concept where an ideal, or 'virtual', representation of a UI is kept in memory and synced with the 'real' DOM by a library such as ReactDOM. This process is called reconciliation."
That names three separate things: the description (the in-memory tree of elements your components return), the real DOM (the browser's displayed tree) and the sync step (reconciliation, which compares the new description with the previous one and hands the renderer the changes). "Virtual DOM" is a name for an idea, not an API or a second DOM you can inspect. The same page separates it from the Shadow DOM, a browser feature "designed primarily for scoping variables and CSS".
Figure 1. From a state change to DOM writes
How to read this: React keeps the previous tree, renders a new one, compares them, and gives the browser only the differences. The real DOM is touched at the end, not during render.
The rules of reconciliation
Comparing two arbitrary trees in general is expensive: the old documentation says the best-known algorithms are cubic in the number of elements. React instead uses a linear heuristic resting on two assumptions the documentation names:
- "Two elements of different types will produce different trees."
- "The developer can hint at which child elements may be stable across different renders with a
keyprop."
Given those assumptions, the rules walk down both trees together, from the root. At each position there are three cases.
Case 1: the element types differ
If the old element was a <div> and the new one is a <span>, or a Counter became a Chart, React "will tear down the old tree and build the new tree from scratch". The old DOM nodes are destroyed, "Any state associated with the old tree is lost", and every component below that position is unmounted.
Case 2: the same DOM element type
If both are a <div>, React keeps the DOM node and updates only the attributes that changed (for style, it "also knows to update only the properties that changed"). Then it compares the children.
Case 3: the same component type
If both are <Counter />, the component instance stays, "so that state is maintained across renders". React gives it the new props, calls the component to get its new output, and continues the comparison with the old and new output.
Figure 2. The decision at one position of the tree
at the same position"] --> B{"Same type?"} B -->|"no"| C["Unmount the old subtree
(state lost, DOM nodes removed)
Mount the new one"] B -->|"yes, DOM element"| D["Keep the DOM node
Update changed attributes
Compare the children"] B -->|"yes, component"| E["Keep the instance and its state
Pass new props
Call it, compare its output"]
How to read this: only the "types differ" branch throws things away. The other two keep identity and go deeper into the tree. Everything below a position inherits the answer for that position.
Comparing lists of children
By default a list of children is compared in lockstep: first old child with first new child, second with second, and a change wherever they differ. Appending is fine: React matches the existing items and inserts the new one. Inserting at the front is poor: "React will mutate every child instead of realizing it can keep the <li>Duke</li> and <li>Villanova</li> subtrees intact", because position 0 used to be Duke and is now Connecticut.
Keys fix this. A key is an identity that travels with the item, not with its position. With key="2015" on Duke, React can see that the item is still in the list, only moved: it uses the key to match children in the old tree with children in the new one.
What the React source does
These statements come from packages/react-reconciler/src/ReactChildFiber.js on the main branch, read on 2026-11-08. They describe one version of React's internals, not a contract.
- For a single child,
reconcileSingleElementcheckschild.key === keyfirst, thenchild.elementType === elementType. A full match reuses the existing fiber (React's internal record of an instance in the tree) with the new props. A key match with a different type deletes the old child and creates a new one. - For an array,
reconcileChildrenArraywalks old and new children together until the first key mismatch, then puts the remaining old children in aMapkeyed bykey(by index when the key isnull) and looks each new child up in it. Found children are kept or moved, new ones inserted, and leftovers deleted.
The old documentation puts it in one sentence: the algorithm "is an implementation detail". React "could rerender the whole app on every action; the end result would be the same." What the heuristics decide is what is kept and what is rebuilt, which you can observe through state and DOM nodes. "Re-render" means "calling render for all components", not unmounting and mounting them: a component can render again and keep all of its state and DOM.
Illustration 1, the minimal example: same slot, what survives?
The lab for the next lesson (examples/frontend/labs/react-render-commit-order) renders the same slot of a tree twice with a change in between and reports what survived. It runs React 19.3.1 under jsdom, a JavaScript model of the DOM: not a browser, so it says nothing about layout or painting, but it does show which nodes exist and which state remains. Part of its output:
same type, new className: same DOM node: true
same type, new className: className is now: after
div replaced by span: same DOM node: false
div replaced by span: old node still in the document: false
same component, same position, other props: button text: plain:1
section to div around a Counter: text before: c:1
section to div around a Counter: text after: c:0
key changed from Taylor to Sarah: text before: score:1
key changed from Taylor to Sarah: text after: score:0
Against the three cases: a <div> whose className changes keeps its node (case 2); a <div> replaced by a <span> gives a different node (case 1); a Counter at the same position with a different label keeps its count, so the earlier click survived (case 3); a clicked Counter moved from a <section> into a <div> starts again at 0 because the parent's type changed (case 1, one level up); and a Counter whose key changes from "Taylor" to "Sarah" starts again at 0, because a key is part of the identity.
State belongs to a position in the tree
The same rules mean that state lives with a component's position in the tree, not with the line of code that wrote it: "it's the position in the UI tree, not in the JSX markup, that matters to React".
Switching branches does not reset a component that lands in the same position of the same parent:
{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}
Toggling isFancy keeps the count: "It's the same component at the same position, so from React's perspective, it's the same counter."
Changing what is at a position resets everything below it. Replace <Counter /> with <p>, or wrap it in <div> instead of <section>, and the old subtree is destroyed. The documentation's rule of thumb: "if you want to preserve the state between re-renders, the structure of your tree needs to 'match up' from one render to another."
To reset on purpose, give the component a key, which tells React "to use the key itself as part of the position". The documentation's example clears a chat draft when you pick another contact:
<Chat key={to.id} contact={to} />
When to.id changes, "the Chat component will be recreated from scratch, including any state in the tree below it." Keys are not globally unique; they only identify an item "within the parent".
Figure 3. State follows the position, and keys are part of it
How to read this: the arrow shows what React sees between two renders. State is only carried across an arrow when the type at each level and the key are unchanged.
The trap: a component defined inside a component
A component function declared inside another component is a new function on every render, and the function is its type. By rule one, a different type means "tear down and rebuild". The documentation concludes: "This is why you should not nest component function definitions." Declare components at the top level of the module.
In the lab, a focused input defined inside a component and then re-rendered by its parent becomes a different DOM node. The old input is no longer in the document, and in jsdom it is not the active element:
component defined inside a component: input was focused: true
component defined inside a component: same input node after re-render: false
component defined inside a component: old input still in the document: false
component defined inside a component: input is still the active element: false
This is the "I type one letter and the field loses focus" bug. The lab shows the DOM part (the node is replaced); how a real browser handles focus is outside what jsdom shows.
Keys in lists
Lists are where items move, appear and disappear, so keys matter most there. The rules from the documentation's "Rendering Lists" page:
- A key is "a string or a number that uniquely identifies it among other items in that array": unique among siblings (the same key in a different array is fine).
- Keys must not change. "Don't generate them while rendering."
- The key goes on the element returned by the
map, not on the root inside an extracted component. - Keys are not props. Pass the id again as a separate prop:
<Profile key={id} userId={id} />. The short fragment<>...</>cannot take a key; use<Fragment key={...}>.
A key comes from data that already has an identity: "the database keys/IDs, which are unique by nature" for fetched data, and for locally created items "an incrementing counter, crypto.randomUUID() or a package like uuid" assigned when you create the item, not when you render it.
Index keys and random keys
If you give no key, React "will use" the index. Writing key={index} is the same choice made visible. It works while the list never reorders: the documentation notes that index keys are fine for lines of a poem, "because a poem's lines will never reorder". Once items can be inserted, deleted or sorted, the position no longer identifies the item and, in the documentation's words, "index as a key often leads to subtle and confusing bugs".
The other mistake is key={Math.random()}. The documentation says this makes keys never match up between renders, "leading to all your components and DOM being recreated every time", which is slow and also loses "any user input inside the list items".
Figure 4. The same reorder with two kinds of key
labels and props are rewritten"| N2["new: 0, 1, 2"] end
How to read this: with id keys the existing row (node and state) travels with its item. With index keys position 0 is "the same row" whatever it now shows, so anything the row holds that React does not rewrite from props stays behind.
Illustration 2, the realistic example: the product comparison page
Here is the page from the start of the lesson. Each row is a component with a name, a price and an uncontrolled quantity input (the browser holds its text, and React does not).
function ProductTable({ products }) {
return (
<ul>
{products.map((product, index) => (
<li key={index}>
<span>{product.name}</span>
<input aria-label={`Quantity for ${product.name}`} defaultValue="" />
</li>
))}
</ul>
);
}
The user types 3 into the first row, then clicks "Sort by price". With key={index}, the old row 0 matches the new row 0: the same <li> and <input>, and React rewrites the name in the <span>. The 3 is held by the browser's input and React never wrote it, so it stays in row 0, which now shows a different product.
The lab reproduces this with three items: text typed into the input of item a, then the list re-rendered in the order c, a, b:
keys by id, order c,a,b: <li> of a is the same node: true
keys by id, order c,a,b: values of the three inputs: ["","typed in a",""]
keys by index, order c,a,b: labels in order: ["c","a","b"]
keys by index, order c,a,b: values of the three inputs: ["typed in a","",""]
With id keys the text moved to the second row along with item a. With index keys the labels are right but the text is still in the first row, which now belongs to c. The fix is one line: key={product.id}.
Illustration 3, the failure case: remounting everything
Three mistakes defeat identity. key={Math.random()} gives every row a new key on every render, so every row remounts and loses input and focus. A component defined inside another gets a new type on every render, so its subtree remounts. A wrapper that sometimes changes type (div to section) resets everything below it. In each case something that should be stable changes, so React correctly treats it as a different thing.
Trade-offs and design choices
| Choice | Benefit | Cost or risk |
|---|---|---|
Stable data id as key |
Rows keep state and DOM through sorts, inserts, deletes | Needs an id in the data model |
Index as key |
No id needed | Wrong for lists that reorder or insert in the middle |
Changing key on purpose |
Resets a subtree's state in one line (the chat draft) | Throws away all state in that subtree, and remounts its DOM |
| Components at module level, stable wrapper types | State survives re-renders and layout changes | Props replace closure variables |
Is the virtual DOM what makes React fast? The documentation does not say so, and this lesson makes no performance claim. Rendering means calling your components, a cost paid whether or not the DOM changes: the lab's dom-writes scenario shows a child that renders again with no DOM mutation. Measure in the profiler before optimising.
In practice: a sortable comparison table that mixes up notes
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A retail comparison page shows up to 20 products (illustrative) in rows. Each row has a "Notes" text area and the page has a "Sort by price" button. Support receives tickets: "I wrote a note about the blue one, sorted by price, and the note is now on a different product." Two engineers own the page.
Starting architecture
Figure 5. Rows keyed by position
How to read this: the warning boxes are where identity is decided. The key is the array index, so the rows stay put and their contents are rewritten.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
Use product.id as the key |
Notes move with their product | Nothing, if every product has a stable id | Ids must be unique among the 20 rows |
| Remount the table on sort with a new key | The page is consistent | All notes lost on every sort | Worse than the bug |
The decision
| Field | Decision record |
|---|---|
| Context | Rows are keyed by array index, so reordering the data leaves browser-held text in the old position. |
| Decision | Key rows by product.id. Add a test that types into a row, sorts, and asserts the text followed the product. |
| Consequences, good | Rows, their DOM nodes and their local state travel with the product. |
| Consequences, bad | Every product needs a stable id; assign one when the list is built if the data has none. |
| Revisit when | Rendering all rows becomes a measured problem. Then look at list techniques, not at the identity rules. |
Rollout and verification
- Write the failing test first in the lab's style (Testing Library under jsdom): type in the first row, re-render in a new order, assert on the text next to each product name.
- Change the key and run the test again, then search for other lists with
key={index}orkey={Math.random()}. - Check in a real browser by hand: type, sort, type, sort again.
The 2 a.m. failure
A week later a character counter ships under each notes field. Earlier, an engineer had written the notes field as a small NotesField component declared inside ProductRow, to read the row's product data. That worked while nothing in the row changed. The counter needs row state that changes on every keystroke, so ProductRow now renders on every keystroke. Support reports that after one letter typed in a notes field, the field stops accepting input until it is clicked again.
The cause is not the key. NotesField is a new function, and so a new component type, on every render of ProductRow. By the first reconciliation rule, a different type at the same position means the old subtree is torn down and a new one built, so the text area is replaced by a fresh node on each keystroke.
First three checks: (1) in the element inspector, watch whether the text area is replaced on each render; (2) search for component declarations inside other components; (3) check whether the problem disappears when NotesField moves to module level. Mitigation: move NotesField out and pass the product data as props. Follow-up: add a lint rule or review checklist item against nested component definitions, and add a test like the lab's "defined inside a component" check.
Architect's notes
- Decide identity early. Every list item needs a stable id in the data model; adding ids later touches the API and the cache.
- Use
keyto reset on purpose: key a subtree by the id of its subject (another user, another document). - Keep component types at module level and wrapper types stable while users type below them.
- Verify identity in tests. It is cheap under jsdom, but does not replace a manual check of focus in a real browser.
Common mistakes
- Using the array index as the key in a list that can be sorted, filtered or edited.
- Generating keys during render (
Math.random(),Date.now()). - Defining a component inside another component.
- Putting the key on the wrong element: on the root inside the extracted component, not on the element in the
map. - Expecting
keyto arrive as a prop. - Believing a re-render recreates the DOM, or that state follows the code rather than the position in the tree.
Key takeaways
- A React element is a plain object that describes UI (
type,props,key). Creating one renders nothing and touches no DOM. - The virtual DOM is a concept: the in-memory tree of elements React compares with the previous one.
- Reconciliation compares old and new trees position by position: different types mean different trees, and keys hint at stable children.
- A different type destroys the old subtree, state included. The same DOM type keeps the node and updates attributes. The same component type keeps its instance and state.
- State belongs to a position in the tree, so changing a parent's type or a component's key resets what is below.
- Keys must be unique among siblings and stable. Use a data id; index keys are safe only for lists that never reorder.
- Never define a component inside another component.
- Rendering is calling components; a component can render again and produce no DOM change.
- The algorithm is an implementation detail; rely on the documented identity behaviour.
Practice exercises
Exercise 1 — Starter (30 min): For each pair, say whether the DOM node and the state are kept: <div className="a"> to <div className="b">; <p> to <span>; <Counter /> to <Counter step={2} />; <Counter key="x" /> to <Counter key="y" />. Check your answers against the lab's output.
Exercise 2 — Intermediate (90 min): Build the product table with key={index} and an uncontrolled input per row. Write a Testing Library test that types in the first row, re-renders in reverse order, and asserts where the text ended up; then switch to key={product.id}.
Exercise 3 — Advanced (half day): Write a 40-line toy that compares two objects of the form { type, key, children } with the three cases from this lesson and prints "keep" or "replace" per position. List which cases it cannot express (keyed reorders, moves between parents).
Related reading
In this course:
- React's Render and Commit Phases, the order in which React calls components, runs effects and cleans up, including what Strict Mode adds. Its lab produced the outputs shown above.
- The Event Loop and the Main Thread, why a long render blocks input.
Official documentation
- React: Render and Commit, trigger, render, commit.
- React: Preserving and Resetting State, state tied to position,
keyas a reset. - React: Rendering Lists, the rules for keys.
- React: createElement, what an element is.
- React (legacy docs, v18.2): Reconciliation and Virtual DOM and Internals, the diffing rules and the definition of the virtual DOM.
- React source: ReactChildFiber.js, single-element and array child reconciliation (read on 2026-11-08).