You already know that your components return JavaScript objects describing the interface. What's missing is the piece that closes the loop: what React does with those objects to turn them into pixels, and above all, how it manages to update the screen when data changes without rebuilding everything from scratch. Understanding this mechanism isn't a theoretical luxury: it explains why lists need key, why a component sometimes mysteriously loses what the user had typed, and why "a render" doesn't mean "touching the DOM." It's the knowledge that separates someone who uses React from someone who understands it.
Contents
- Two trees: the real DOM and React's element tree
- Why creating objects is cheap and touching the DOM is expensive
- The three phases: trigger, reconciliation, and commit
- The diffing algorithm's heuristics
- Same element type: it updates
- Different type: it's destroyed and recreated (and state is lost)
- Lists need a stable identity
- Render, commit, and browser repaint
- A render doesn't always touch the DOM
- Two trees: the real DOM and React's element tree
When the browser loads a page, it builds the DOM (Document Object Model): a tree of objects representing every tag. Those objects are heavy. A single <div> in the DOM has hundreds of properties and methods: geometry, computed styles, events, accessibility, relationships with its neighbors.
What your components return is something completely different:
// This is what you write...
<article className="bike-card">
<h3>Classic Urban</h3>
<p>€2.50 / hour</p>
</article>// ...it turns into a tiny JavaScript object, something like:
{
type: 'article',
props: {
className: 'bike-card',
children: [
{ type: 'h3', props: { children: 'Classic Urban' } },
{ type: 'p', props: { children: '€2.50 / hour' } }
]
}
}That object is a React element. It isn't a DOM node: it has no methods, it isn't on screen, it takes up no space, it knows nothing about pixels. It's a description, a recipe. And since it's just data, creating it costs practically nothing.
The complete tree of those objects that React keeps in memory is historically called the Virtual DOM. The name is somewhat unfortunate — it's neither a DOM nor virtual — but it's so entrenched that we'll use it anyway. What matters is the concept: React keeps a lightweight in-memory copy of what the interface should look like, and uses it to decide what to change in the real DOM.
flowchart LR
subgraph M[In memory: cheap]
A[Element tree<br/>lightweight JS objects]
end
subgraph N[In the browser: expensive]
B[Real DOM<br/>heavy nodes]
end
A -- "React applies only the differences" --> B
- Why creating objects is cheap and touching the DOM is expensive
React's entire strategy rests on a cost asymmetry.
Creating JavaScript objects is dirt cheap. Allocating memory for a handful of plain objects is one of the fastest operations a JavaScript engine performs. Building an element tree for a hundred bike cards costs a fraction of a millisecond.
Modifying the DOM is expensive, and not because of the write itself, but because of what it triggers:
| Operation | What it causes in the browser |
|---|---|
| Changing a text | Repaint of that area |
Changing color or background |
Repaint |
| Changing size, position, or inserting a node | Reflow: recalculating the geometry of the whole page |
Reading offsetHeight right after writing |
Forces an immediate, synchronous reflow |
The reflow is what's truly costly: the browser has to recalculate where every element on the page goes. Doing it a hundred times in a row inside a loop is the classic recipe for a janky interface.
That's why React prefers to think a lot in memory in order to touch the DOM as little as possible. Compare two ways of updating a bike's status in a list of fifty:
// Naive approach with direct DOM manipulation: destroy and rebuild everything
container.innerHTML = ''; // 50 nodes destroyed
bikes.forEach((b) => buildCard(b)); // 50 nodes created + 50 insertions
// Result: massive reflow, focus and scroll position are lost// React's approach: // 1. Builds 50 lightweight objects in memory (microseconds) // 2. Compares them with the previous 50 (microseconds) // 3. Detects that only one text changed // 4. Runs ONE operation: textNode.data = 'alquilada' // Result: minimal repaint, focus and scroll stay intact
An honest and important nuance: the Virtual DOM isn't magic, nor is it inherently faster than perfectly optimized, hand-written imperative code. An expert who updates exactly the node that needs it will always win a micro-benchmark. What React brings is that you get that near-optimal result by default, without thinking about it, and in a way that scales as the application grows. You trade a bit of theoretical performance for a great deal of predictability and maintainability.
- The three phases: trigger, reconciliation, and commit
Every interface update always goes through the same three phases.
flowchart TD
A["1. TRIGGER<br/>Initial render or state change"] --> B["2. RENDER + RECONCILIATION<br/>React runs the components and compares<br/>the new tree with the previous one"]
B --> C{"Are there differences?"}
C -- No --> D["Done: the DOM isn't touched"]
C -- Yes --> E["3. COMMIT<br/>React applies to the real DOM<br/>only the differences it found"]
E --> F["The browser repaints the affected area"]
Phase 1: the trigger
There are only two reasons React renders:
- The initial render, when you call
createRoot(...).render(<App />). It happens exactly once. - A state change, when a component updates its state with the function
useStategives it. You'll study this in Component State.
It's a very short list, and it's worth memorizing, because it bounds everything that can make the screen change.
Phase 2: render and reconciliation
React runs your component functions. That's literally what "rendering" means: calling App(), which returns elements, some of which are components that React also calls, recursively, until it has the complete tree.
With the new tree in hand, it compares it against the one it already had. That comparison process is called reconciliation or diffing, and its result is a list of concrete operations: "change this text," "add this class," "insert this node," "remove that one."
A fundamental consequence: rendering doesn't touch the DOM. The render phase is a pure in-memory calculation. That's why your components must be pure functions: given the same data, they must always return the same result, with no side effects. React reserves the right to run them as many times as it needs to (remember StrictMode, which runs them twice in development precisely to surface impurities).
Phase 3: the commit
React applies the calculated list of operations to the real DOM, and only that. If only one text changed, it runs a single instruction. If nothing changed, it doesn't touch the DOM at all.
All modifications are applied synchronously and in a batch, so the user never sees an inconsistent intermediate state.
- The diffing algorithm's heuristics
Comparing two arbitrary trees and finding the minimum number of changes is an O(n³) problem: for a thousand elements that would be a billion comparisons, unworkable at 60 frames per second.
React solves this by giving up on perfection and adopting two assumptions that are almost always true in real interfaces. With them, the cost drops to O(n), linear:
- Two elements of different types produce different trees. If where there used to be an
<article>there's now a<section>, React doesn't try to reuse anything: it destroys and recreates. - The developer can indicate which children are stable across renders via the
keyprop.
React also never compares elements at different levels of the tree: it walks both trees in parallel, level by level, comparing position against position. If a node moves to a different level, as far as React is concerned it's a different node.
These heuristics aren't an internal detail: they're the rules you need to know to write components that behave well. The next two sections develop the first one; the seventh develops the second.
- Same element type: it updates
If the element at the same position keeps its type, React keeps the DOM node and only updates the attributes that changed.
// Previous render
<article className="bike-card">
<h3>Classic Urban</h3>
<p>disponible</p>
</article>
// New render
<article className="bike-card bike-card--featured">
<h3>Classic Urban</h3>
<p>alquilada</p>
</article>React compares and decides:
| Element | Comparison | Action |
|---|---|---|
<article> |
Same type | Keeps the node; updates className |
<h3> |
Same type, same content | Does nothing |
<p> |
Same type, different text | Keeps the node; updates only the text |
Two operations in total. The <article> node is the same DOM object as before: no new one was created. This has very visible consequences:
- If the user had focus on a field inside that card, it stays.
- If a CSS animation was running, it continues.
- If there was an
<input>with text the user had typed, the text is still there.
The same logic applies to your components: if there was a <BikeCard /> at a given position and there's still a <BikeCard /> there, React treats it as the same component, keeps its internal state, and simply runs it again with the new data.
- Different type: it's destroyed and recreated (and state is lost)
When the type changes, React applies the first heuristic without hesitation: it destroys the entire subtree and builds it from scratch.
// Previous render
<article className="card">
<BookingForm />
</article>
// New render: article -> section
<section className="card">
<BookingForm />
</section>Even though the inner content is identical, React removes the <article> along with everything hanging off it and creates a new <section>. BookingForm gets unmounted and remounted from scratch: it loses all its internal state. If the user had typed their name and the booking hours, that data vanishes.
This explains a bewildering and very real bug. Look at this:
// PROBLEMATIC: the container's type changes based on state
function BookingPanel() {
const [compactMode, setCompactMode] = useState(false);
if (compactMode) {
return (
<div className="compact-panel">
<BookingForm />
</div>
);
}
return (
<section className="wide-panel">
<BookingForm />
</section>
);
}Every time the user toggles between compact and wide mode, the container switches from div to section: a different type, the subtree gets destroyed, the form is wiped out. The user perceives that "the app clears what I typed when I switch views," and the real cause is two levels away from the form.
The fix is to keep the same element type and only vary what actually changes:
// CORRECT: always the same type, only the class changes
function BookingPanel() {
const [compactMode, setCompactMode] = useState(false);
return (
<section className={compactMode ? 'compact-panel' : 'wide-panel'}>
<BookingForm />
</section>
);
}Now React recognizes the same <section> at the same position, updates only className, and the form keeps whatever the user had typed intact.
Operational summary:
| Situation | React's decision | Effect on state |
|---|---|---|
Same type (div → div) |
Reuses the node, updates props | Preserved |
Same component (<Card /> → <Card />) |
Reuses the instance, runs it again | Preserved |
Different type (div → section) |
Destroys and recreates the subtree | Lost |
Different component (<Card /> → <Row />) |
Destroys and recreates | Lost |
| Element removed from the tree | Unmounts | Lost |
The underlying idea: to React, a component's identity is "its type at its position in the tree." Change either one and, in its eyes, it's a different component.
- Lists need a stable identity
That definition of identity — type plus position — works fine for fixed structures, but it breaks down with lists. Picture CicloUrbano's catalogue:
Previous render: New render (a bike is added at the front):
0: Classic Urban 0: Electric Pro <- new
1: Electric Pro 1: Classic Urban
2: Cargo Max 2: Electric Pro
3: Cargo MaxComparing by position, React sees that at index 0 the text went from "Classic Urban" to "Electric Pro," at index 1 from "Electric Pro" to "Classic Urban," at index 2 from "Cargo Max" to "Electric Pro," and that there's a new element at index 3. Conclusion: it updates the content of the three cards and adds a fourth. In reality, only one card needed to be inserted at the front, and the rest didn't need touching at all.
Besides being inefficient, it's incorrect: the internal state of each card (an open dropdown, an animation, a filled-in hours field) stays glued to the position and ends up attached to the wrong bike.
The solution is the key prop: a stable identity that tells React "this element is this one, no matter where it is now."
With key={bike.id}, React stops comparing by position and compares by identity. It sees that bici-001, bici-002, and bici-003 already existed and simply moved, and that bici-005 is new. Result: a single insertion, and each card keeps its own state, wherever it ends up.
The essential rules for key, in one sentence each:
- It must be unique among siblings (not across the whole application).
- It must be stable: the same key for the same piece of data on every render.
- The data's identifier (
bike.id) is almost always the best choice. - Never use the array index if the list can be reordered, filtered, or grow from the front: the index is exactly the position, so it carries no identity at all and reproduces the very problem you were trying to solve.
- And never
Math.random(): it would be different on every render, so React would destroy and recreate the entire list each time.
Here you only needed to understand why key exists, which is a direct consequence of the reconciliation algorithm. The full mechanics of rendering lists are covered in Lists and Keys.
- Render, commit, and browser repaint
Three words that sound alike and mean different things. Confusing them makes half of all conversations about React performance incomprehensible.
| Term | Who does it | What it is | Is it expensive? |
|---|---|---|---|
| Render | React | Running your component functions and comparing trees in memory | Cheap |
| Commit | React | Applying the detected differences to the real DOM | Depends on how many there are |
| Repaint | The browser | Recalculating geometry and drawing pixels on screen | Can be very expensive |
The complete sequence, in order:
sequenceDiagram
participant U as User
participant R as React
participant D as DOM
participant B as Browser
U->>R: Interaction (state change)
R->>R: Render: runs the components
R->>R: Reconciliation: compares trees
alt There are differences
R->>D: Commit: applies the changes
D->>B: Reflow and repaint
B-->>U: Screen updated
else No differences
R-->>R: Done: the DOM isn't touched
end
Notice that the browser only repaints if there was a commit, and there was a commit only if there were differences. Each link in the chain can break it.
- A render doesn't always touch the DOM
Everything above leads to the statement that clears up the most misunderstandings:
A component rendering doesn't mean the DOM changes.
React can run your component, get exactly the same element tree as before, find no difference, and end up not touching a single node. The cost of that operation is just running your functions and comparing objects in memory: normally, irrelevant.
A concrete example. Suppose a bike counter that's recalculated on every render:
function FleetSummary() {
const available = bikes.filter((b) => b.status === 'disponible').length;
return <p>{available} bikes available</p>;
}If the component renders again but available is still 3, React generates <p>3 bikes available</p>, compares it with the previous one, sees it's identical, and doesn't touch the DOM. The user notices absolutely nothing, there's no reflow, there's no repaint.
The practical implications:
- An extra render isn't automatically a performance problem. It only becomes one when it happens very often, over very large trees, or when heavy calculations happen inside it.
- Optimizing before measuring is a classic mistake. There are tools to avoid unnecessary renders (
React.memo,useMemo,useCallback), and they're covered in Module 8, but applying them "just in case" adds complexity and usually makes things worse. Measure first, with the React DevTools Profiler; then optimize whatever the measurement points to. - What you should always avoid is doing heavy work inside the component function: network calls, huge loops, operations over thousands of elements. That runs on every render, and there the cost is real.
With this you have the complete mental model: your components describe, React compares, and only the difference reaches the DOM.
Common Mistakes and Tips
- Believing the Virtual DOM is always "faster." It's faster than repainting everything by hand and far more sustainable, but it doesn't beat minimal, perfectly optimized imperative code. Its value is in getting a near-optimal result by default.
- Thinking that rendering means painting. Rendering is running your functions and comparing objects. Painting is done by the browser, and only after a commit with real changes.
- Changing the container element's type based on a condition. It causes the subtree to be destroyed and state to be silently lost. Keep the type and change the props instead.
- Using the array index as
keyin lists that get reordered, filtered, or grow from the front. It's the number-one cause of state that ends up "stuck" to the wrong row. - Using
Math.random()orDate.now()askey. It destroys and recreates the entire list on every render: the worst possible performance, and it also loses focus and scroll position. - Writing impure components that modify external variables, write to
localStorage, or make requests directly in the function body. React can run them multiple times; side effects have their place, covered in The useEffect Hook. - Optimizing without measuring. Adding preventive memoization complicates the code and often slows it down. Measure first (lesson 08-05).
- Tip: whenever something "clears itself" in your interface, ask what element changed type or position in the tree. The answer is almost always there.
Exercises
Exercise 1
For each pair of renders, state what React does (reuses the node, or destroys and recreates it) and whether the child components' internal state survives.
Case A
// before
<div className="card"><BookingForm /></div>
// after
<div className="card card--active"><BookingForm /></div>Case B
// before
<div className="card"><BookingForm /></div>
// after
<section className="card"><BookingForm /></section>Case C
Exercise 2
CicloUrbano's catalogue shows a list of bikes, and each card has a field where the user types how many hours they want to book. The code uses the index as the key:
The user types "3" into the card for the "Cargo Max," which is in third position. They then turn on the "available only" filter, which removes the first bike from the list because it's rented. Describe what the user will see, explain why it happens, and fix the code.
Exercise 3
A colleague claims: "I measured with the Profiler, and this component renders 40 times a second while the user drags the hours control. We need to wrap it in React.memo right now." The component in question is this:
function PriceSummary({ pricePerHour, hours }) {
return <p>Total: €{(pricePerHour * hours).toFixed(2)}</p>;
}Is their reasoning correct? Justify your answer using the concepts of render, commit, and repaint.
Solutions
Solution 1.
Case A — Reuses. The type is the same (div → div) and so is the position. React keeps the DOM node and only updates the className attribute. BookingForm is treated as the same component, gets run again, and keeps its state: whatever the user had typed is still there.
Case B — Destroys and recreates. div and section are different types, so React applies the first heuristic: it removes the div along with its entire subtree and creates a new section. BookingForm gets unmounted and remounted from scratch: the state is lost. This is the dangerous case, because the inner content looks identical.
Case C — Destroys and recreates. The div container does get reused, but its child goes from BikeCard to BikeRow: different components at the same position. React unmounts the first and mounts the second, and the state is lost. That both show similar data is irrelevant: to React, they're different types.
Solution 2.
What the user sees: the "3" they typed for the "Cargo Max" now shows up on another bike's card, and the "Cargo Max" shows an empty field or another bike's value. The data "jumps" between cards.
Why it happens: with key={index}, the key of the third element is 2 both before and after filtering. But once the first bike is removed, index 2 no longer holds the "Cargo Max" — it holds whatever previously sat at index 3. React sees the same key 2 at the same position, concludes it's the same component, keeps its state, and only updates the props. Result: the user's input stays anchored to the position, not to the bike. The index isn't an identity: it's literally the position, exactly what key was supposed to stop relying on.
Fix:
Now the "Cargo Max"'s key is bici-003 wherever it ends up. When filtering, React recognizes that component still exists, moves it to its new position, and keeps its state with the correct bike. It also detects that only one element disappeared, so it performs a single removal in the DOM instead of rewriting the whole list.
Solution 3.
The reasoning isn't correct, or at least it's premature. It confuses "render" with "cost."
- The render of
PriceSummaryconsists of a multiplication, atoFixed, and the creation of a tiny element object. Forty of those operations per second are irrelevant on any modern device. - The commit only happens if the result changed. And here it does change — the user is dragging the hours control, so the total varies — but it translates into a single text update: no page reflow, no node insertions or removals.
- The repaint affects one line of text. It's one of the cheapest operations a browser can do.
Also, React.memo wouldn't help at all here: its job is to avoid renders when props don't change, but here hours changes on every move of the control. Adding it would introduce a props comparison on every render without preventing a single one: net positive cost, zero benefit.
The right approach: a high render count isn't a diagnosis, it's a data point. You need to measure with the Profiler how much time those renders consume and whether any frame gets dropped. If the drag feels smooth, there's nothing to fix. And if there were a problem, it would almost certainly be in a sibling component doing heavy work and rendering unnecessarily, not in this one. These tools and how to apply them are covered in Module 8.
Conclusion
You now know the engine underneath React. Your components don't generate HTML: they produce lightweight objects that form a tree in memory, the so-called Virtual DOM. When something changes, React runs through three phases — trigger, render with reconciliation, and commit — and applies to the real DOM only the differences it found, because creating objects is cheap and touching the DOM is expensive. Its algorithm relies on two heuristics you now know how to read: same type at the same position gets reused and keeps its state; different type gets destroyed and loses it, and lists need a stable identity (key) because position alone isn't enough. And you have a clear grasp of the distinction between render, commit, and repaint, which is what lets you say, without contradiction, that a render doesn't always touch the DOM.
With this lesson you close out Module 1. You've walked the full path from why to how: you know what problem React solves and when it's the right fit, you have the CicloUrbano project set up with Vite and running with hot reload, you've written your first components of your own, you've mastered JSX syntax with its rules and its interpolation, and you understand the rendering mechanism that underpins everything. You're no longer copying code: you're building with judgment.
From here it's time to dig into React's central piece. In Module 2: React Components you'll learn to design well-scoped, reusable components, to read legacy code written with classes, to pass them data from outside with props so your cards stop being fixed, to give them their own memory with state — the other render trigger you've just studied — and to style them with strategies that hold up as the application grows. CicloUrbano really begins in the next lesson.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
