The Virtual DOM
React maintains a lightweight in-memory representation of the actual DOM called the virtual DOM. When state changes, React builds a new virtual DOM tree, diffs it against the previous one, and applies only the minimal set of real DOM mutations needed.
// React creates virtual DOM elements — plain objects, not real DOM nodes
const element = <h1 className="title">Hello</h1>;
// This is equivalent to:
const element = {
type: "h1",
props: {
className: "title",
children: "Hello",
},
};This abstraction is what makes React declarative — you describe what the UI should look like, and React figures out how to update the DOM efficiently.
Reconciliation
Reconciliation is the algorithm React uses to diff two virtual DOM trees. It relies on two heuristics:
- Different element types produce different trees — React tears down the old subtree entirely.
- Keys let React identify which items in a list changed, moved, or were removed.
// BAD: Using index as key causes unnecessary re-mounts when items reorder
{items.map((item, index) => (
<ListItem key={index} data={item} />
))}
// GOOD: Stable, unique keys let React track items correctly
{items.map((item) => (
<ListItem key={item.id} data={item} />
))}When React encounters the same component type at the same position, it reuses the existing instance and updates its props — this is why keys matter for correctness, not just performance.
What Triggers a Re-render?
A component re-renders when:
- Its state changes (via
useState,useReducer). - Its parent re-renders (props may or may not have changed).
- A context it consumes changes.
function Parent() {
const [count, setCount] = useState(0);
// Every time count changes, Parent re-renders,
// which also re-renders Child — even though Child
// receives no props related to count.
return (
<div>
<button onClick={()=> setCount((c)=> c + 1)}>Increment</button>
<Child />
</div>
);
}
function Child() {
console.log("Child rendered");
return <p>I am a child</p>;
}This is the single most important concept for React performance: parent re-renders cascade to all children by default.
Automatic Batching
React 18+ batches multiple state updates into a single re-render, even inside async functions and event handlers.
function Form() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit() {
// All three updates are batched into ONE re-render
setSubmitting(true);
setName("");
setEmail("");
await submitForm({ name, email });
// This update is also batched (React 18+)
setSubmitting(false);
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e)=> setName(e.target.value)} />
<input value={email} onChange={(e)=> setEmail(e.target.value)} />
<button disabled={submitting}>Submit</button>
</form>
);
}If you ever need to force a synchronous DOM update, you can use flushSync — but this is rarely necessary and should be avoided for performance.
import { flushSync } from "react-dom";
function handleClick() {
flushSync(() => {
setCount((c) => c + 1);
});
// DOM is updated here
console.log(document.getElementById("counter")!.textContent);
}Avoiding Unnecessary Re-renders
Understanding the render cycle lets you make targeted optimizations:
// Move state DOWN to the component that needs it
function Page() {
return (
<div>
<Header />
<SearchSection /> {/* state lives here, not in Page */}
<ExpensiveList />
</div>
);
}
function SearchSection() {
const [query, setQuery] = useState("");
return (
<div>
<input value={query} onChange={(e)=> setQuery(e.target.value)} />
<SearchResults query={query} />
</div>
);
}By colocating state with the components that use it, you prevent unrelated siblings from re-rendering.