Skip to content

    Frontend System Design

    React Reconciliation and the Virtual DOM

    Learn how React compares two element trees, what it keeps or throws away (DOM nodes, state), and how keys decide identity in lists, so you can predict updates.

    By Published 17 min read

    • frontend-system-design
    • react
    • reconciliation
    • virtual-dom
    • keys
    Share

    Flagship lessonintermediateInteractive simulatorFrontend System Design

    Before this: The Event Loop and the Main Thread, The DOM, Event Propagation and Event Delegation (coming soon)

    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.

    1. 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.
    2. 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.
    3. Reconciliation compares the old and new descriptions, position by position, using two rules of thumb. Elements of different types produce different trees, and a key hints which children are the same across renders.
    4. 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

    A state update leads to a new element tree, a comparison with the previous tree, and then DOM writesState update to Render; Render to New element tree; New element tree to Reconcile; Previous tree to Reconcile; Reconcile to Real DOM (changes)INSIDE REACTState update: setState callState updatesetState callRender: call componentsRendercall componentsNew element tree: plain objectsNew elementtreeplain objectsReconcile: compare with last treeReconcilecompare with lasttreePrevious tree: kept by ReactPrevious treekept by ReactReal DOM: only changed partsReal DOMonly changedpartschanges

    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:

    1. "Two elements of different types will produce different trees."
    2. "The developer can hint at which child elements may be stable across different renders with a key prop."

    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

    flowchart TD accTitle: Reconciliation decision at one position accDescr: React compares the old and new element at the same position. Different types destroy the old subtree and build a new one. The same DOM type updates changed attributes and compares children. The same component type keeps state, passes new props, calls the component, and compares its output. A["Old and new element
    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, reconcileSingleElement checks child.key === key first, then child.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, reconcileChildrenArray walks old and new children together until the first key mismatch, then puts the remaining old children in a Map keyed by key (by index when the key is null) 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

    flowchart LR accTitle: State kept or reset depending on the tree position accDescr: Three cases side by side. Same component at the same position keeps its state. A different parent type or a different component at the position resets it. A changed key resets it. subgraph K["Kept"] K1["section: Counter (count 3)"] -->|"re-render, new props"| K2["section: Counter (count 3)"] end subgraph R["Reset"] R1["section: Counter (count 3)"] -->|"parent becomes div"| R2["div: Counter (count 0)"] R3["Counter key=Taylor (count 3)"] -->|"key becomes Sarah"| R4["Counter key=Sarah (count 0)"] end

    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

    flowchart LR accTitle: Reordering a list with id keys and index keys accDescr: The list a, b, c becomes c, a, b. With id keys each old item is matched to the new item with the same id, so the items move. With index keys each old item is matched to the new item at the same position, so the items stay and their contents are overwritten. subgraph ID["key = id"] direction LR O1["old: a, b, c"] -->|"match by id: a, b, c move"| N1["new: c, a, b"] end subgraph IX["key = index"] direction LR O2["old: 0, 1, 2"] -->|"match by position: nodes stay,
    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

    NOTE

    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

    Starting architecture: the sort button changes the array order, rows are keyed by positionSort by price to List state; List state to ProductTable (new order); ProductTable to Rows (rewrite by position)BROWSER PAGESort by price: button clickSort by pricebutton clickList state: order changesList stateorder changesProductTable: key = array indexProductTablekey = array indexRows: notes live in DOMRowsnotes live in DOMnew orderrewrite 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

    1. 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.
    2. Change the key and run the test again, then search for other lists with key={index} or key={Math.random()}.
    3. 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 key to 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 key to 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).

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments