How React Works (Virtual DOM)
React’s core promise is simple: you describe what the UI should look like for a given state, and React figures out how to update the real DOM to match. It does this efficiently using an in-memory representation called the Virtual DOM, combined with a process called reconciliation. Understanding this process is the single most important thing you can learn early in React — it explains why state changes trigger re-renders, why lists need key props, and why React is fast even though the real DOM is slow to manipulate.
Overview / How it works
Manipulating the real DOM is expensive. Every time you change an element’s text, add a node, or restyle something, the browser may need to recalculate layout, repaint pixels, and update the accessibility tree. If you wrote an app that directly poked the DOM every time data changed, you would end up doing far more work than necessary, and it would be hard to keep the UI in sync with your data.
React solves this with an indirection layer. Instead of describing DOM mutations yourself, you write components that return a description of the UI, written in JSX. When JSX like <h1>Hello</h1> is compiled, it becomes a call to React.createElement('h1', null, 'Hello'), which returns a plain JavaScript object — not a real DOM node. This tree of plain objects is the Virtual DOM: a lightweight, in-memory description of what the UI should look like.
Every time a component renders (on mount, or after a state/prop change), React builds a new Virtual DOM tree for that part of the UI. It then compares this new tree with the previous one using an algorithm called the diffing algorithm. This comparison step, together with figuring out the minimal set of real DOM operations needed to reflect the differences, is called reconciliation. Only after reconciliation decides what actually changed does React touch the real DOM, in a step called the commit phase.
Because comparing JavaScript objects in memory is dramatically cheaper than touching the real DOM, React can afford to re-run your component functions often (they are just JavaScript) and still only perform a handful of real, targeted DOM writes. This is the entire reason React feels declarative: you say “this is what the UI looks like now,” and React works out the cheapest path from the old real DOM to that new description.
The render phase vs. the commit phase
React’s work splits into two phases:
- Render phase — React calls your component functions, builds the new Virtual DOM tree, and diffs it against the previous tree. This phase is pure and can, in theory, be paused, thrown away, or redone by React (this is what enables features like concurrent rendering). Your component functions must not have side effects here — that is exactly why side effects belong in
useEffect, not directly in the component body. - Commit phase — React takes the list of changes computed during the render phase and applies them to the real DOM in one batch. This is also when refs are attached and layout effects run.
Why diffing needs rules (and why keys matter)
Comparing two arbitrary trees element-by-element in the general case is computationally expensive (classic tree-diff algorithms are O(n³)). React makes this fast by using a heuristic, O(n) algorithm based on two assumptions:
- Two elements of different types (e.g. a
<div>becoming a<span>, or one component type becoming another) produce different trees. React tears down the old subtree completely and builds a fresh one, rather than trying to diff their children. - Elements in a list that have a stable, unique
keyprop stay consistent across renders. React uses the key to match old and new elements so it can reorder, reuse, or update them instead of destroying and recreating them.
This is why lists rendered without keys (or with array-index keys on lists that reorder) can cause subtle bugs: React may misidentify which DOM node corresponds to which data item, causing stale input values, broken animations, or unnecessary re-mounts.
Syntax
You do not call the Virtual DOM or reconciliation APIs directly — they run automatically whenever a component renders. What you write is JSX, and what actually starts the whole process is mounting a root component to a real DOM node:
import { createRoot } from "react-dom/client";
import App from "./App";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
| Part | Meaning |
|---|---|
createRoot(domNode) |
Creates a React root attached to a real DOM container. This is the React 18+ API and replaces the legacy ReactDOM.render. |
root.render(<App />) |
Tells React to build the initial Virtual DOM tree for <App /> and commit it to the real DOM. Calling render again (which React does internally after state changes) triggers a diff against the previous tree. |
<App /> |
JSX that compiles to React.createElement(App, null), producing a plain object — the Virtual DOM node, not a real element. |
Examples
Example 1: JSX becomes Virtual DOM objects
function Greeting() {
return <h1 className="title">Hello, world!</h1>;
}
console.log(<Greeting />);
Output:
{
type: Greeting,
props: {},
key: null,
...
}
This example makes the abstraction concrete: JSX is not HTML and is not a real DOM node. <Greeting /> compiles to a plain JavaScript object describing “render whatever the Greeting function returns.” React only turns this description into real <h1> DOM nodes during the commit phase, after diffing.
Example 2: A state update triggers render, diff, and a targeted DOM write
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
export default Counter;
This renders a paragraph reading “Count: 0” and a button. Clicking the button calls setCount, which schedules a re-render. React re-runs the Counter function, producing a new Virtual DOM tree where the text node changes from "Count: 0" to "Count: 1". When React diffs the new tree against the previous one, it finds that the <div> and <button> are structurally identical (same element type, same position) but the text inside the <p> differs. During the commit phase, React updates only that text node in the real DOM — it does not destroy and recreate the <div>, the button, or its click handler.
Example 3: Keys let React reuse DOM nodes across renders
import { useState } from "react";
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn JSX" },
{ id: 2, text: "Learn hooks" },
]);
function addTodo() {
const next = { id: Date.now(), text: "New task" };
setTodos([next, ...todos]);
}
return (
<div>
<button onClick={addTodo}>Add to top</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
export default TodoList;
This renders a button and a two-item list. Clicking “Add to top” inserts a new todo at the front of the array. Because each <li> has a stable key based on the todo’s id (not its array index), React’s diffing matches the existing <li> elements by key, sees that a brand-new key was inserted at the front, and only creates one new <li> DOM node — it reuses the two existing list items rather than re-rendering the whole list from scratch. If the code had used the array index as the key instead, React would think item 0 changed text (not that a new item was added), leading to unnecessary DOM updates and, in lists with inputs or animations, visibly broken behavior.
How it works step by step (Under the hood)
On mount:
- React calls your component function top to bottom, collecting the JSX it returns into a Virtual DOM tree.
- Since there is no previous tree to diff against, React treats every node as new and constructs the entire real DOM subtree, then inserts it into the container passed to
createRoot. - After the DOM is updated, React runs layout effects, then
useEffectcallbacks.
On a state update (e.g. calling setCount):
- React schedules a re-render of the component (and, by default, its children) rather than updating the DOM immediately.
- Multiple state updates that happen in the same event handler are batched into a single re-render for performance.
- React re-invokes the component function, producing a new Virtual DOM tree.
- React diffs the new tree against the previous tree, node by node, matching elements by type and by
key. - React computes the minimal set of real DOM mutations (text changes, attribute changes, insertions, removals, reorders) and applies them in the commit phase.
- After committing, cleanup functions from the previous render’s effects run (if dependencies changed), followed by new effect callbacks.
On unmount:
- React removes the component’s DOM nodes from the real DOM.
- Effect cleanup functions (the function returned from
useEffect) run so timers, subscriptions, and listeners are torn down.
This is also why hooks must always run in the same order on every render: React does not track hooks by name, it tracks them by the order they are called in, matching call #1 in this render to call #1 in the last render. If a hook call is skipped by a conditional, every hook after it shifts position and gets matched to the wrong stored state.
Common Mistakes
Mistake 1: Mutating state directly instead of creating a new value
function TodoList() {
const [todos, setTodos] = useState(["Learn JSX"]);
function addTodo() {
todos.push("Learn hooks"); // mutates the existing array in place
setTodos(todos);
}
// ...
}
This is wrong because React’s diffing (and its decision to re-render) relies on comparing the previous Virtual DOM tree, built from the previous state value, against a new one. If you mutate the same array reference and pass that same reference back into setTodos, React may not even detect that state changed (for objects, React does not deep-compare; it just checks whether the top-level reference changed) so the UI silently fails to update. The fix is to always produce a new array or object:
function addTodo() {
setTodos([...todos, "Learn hooks"]);
}
Mistake 2: Missing or unstable keys in a list
{todos.map((todo, index) => (
<li key={index}>{todo.text}</li>
))}
Using the array index as a key seems harmless, but it breaks down as soon as items are inserted, removed, or reordered anywhere except the very end. Because the index is really “position,” not “identity,” React’s diffing will match the wrong old element to the wrong new data, which can misplace input focus, animate the wrong element, or preserve component-local state (like a checked checkbox) on the wrong row. Always key list items by a stable, unique identifier from the data itself:
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
Mistake 3: Reading or writing the real DOM directly instead of letting React manage it
function Counter() {
const [count, setCount] = useState(0);
function increment() {
setCount(count + 1);
document.querySelector("p").textContent = count + 1; // fighting React
}
// ...
}
Manually mutating a DOM node that React also manages creates a conflict: React’s Virtual DOM still thinks the text is whatever it last rendered, so on the next render React may overwrite your manual change, or diffing may behave unpredictably because the real DOM no longer matches what React believes it committed. Let state and JSX be the single source of truth, and use useRef only for things React does not manage (focusing an input, measuring an element), never to bypass rendering.
Best Practices
- Treat state and props as immutable — always create new arrays/objects (
{ ...obj },[...arr]) instead of mutating existing ones, so React’s change detection and diffing work correctly. - Always give list items a stable, unique
keyderived from the data (an id), never the array index, unless the list is static and never reorders. - Keep component functions pure during the render phase — no direct DOM manipulation, no side effects, no reading/writing external mutable variables. Put side effects in
useEffect. - Remember that a parent re-rendering re-renders its children by default; use tools like memoization (covered in later lessons) only after you’ve confirmed a real performance problem, not preemptively.
- Trust the declarative model: describe what the UI should look like for the current state, and let React’s reconciliation figure out the DOM operations, rather than trying to imperatively script DOM changes yourself.
Practice Exercises
- Write a
ToggleBoxcomponent with a boolean piece of state and a button that flips it withsetIsOn(!isOn). Render different text depending on the boolean. Predict, before running it, which DOM nodes React will actually change on each click. - Take the
TodoListexample from this lesson and deliberately switch thekeyback to the array index. Add a text input to each<li>and type into one before adding a new item to the top of the list — observe how the typed text ends up on the wrong row, and explain why using what you learned about diffing. - Write a component that renders a list of five colored boxes with unique
keys, plus a button that shuffles the array order withsetBoxes([...shuffled]). Explain, in your own words, why React reorders the existing DOM nodes instead of destroying and recreating all five.
Summary
- JSX compiles to
React.createElementcalls that produce plain JavaScript objects — the Virtual DOM — not real DOM nodes. - React re-renders by calling your component functions again and building a new Virtual DOM tree whenever state or props change.
- Reconciliation diffs the new tree against the previous one using an O(n) heuristic algorithm, matching elements by type and by
key. - The render phase computes what changed (pure, no side effects); the commit phase applies the minimal set of real DOM mutations and runs effects.
- Stable, unique
keys let React correctly reuse DOM nodes and component state across renders instead of destroying and rebuilding them. - Never mutate state directly, never manipulate the DOM React manages, and never call hooks conditionally — each of these breaks the assumptions reconciliation and hooks rely on.
