How React updates the page
Why this matters
In the previous topics we wrote setCount(count + 1) and "React re-rendered the component". It's time to figure
out what exactly happens then. Without this React stays magic, and most beginner bugs — "why didn't the screen
update", "why is the value old", "why didn't the field clear" — make no sense. After this topic you'll be able to
predict what React will do before you even run the code.
Three steps: trigger, render, commit
Any screen update in React goes through three steps:
- Trigger. Something requests an update: the first render
root.render(...)or a call to aset…function fromuseState. - Render. React calls the component functions. Each returns JSX — a description of what should be on the screen. The page itself doesn't change yet at this step.
- Commit. React compares the new description with the previous one and changes in the real DOM only what is
different. After that the effects (
useEffect) run.
"Rendering" in React isn't drawing pixels, it's just calling your function.
What JSX returns
JSX isn't HTML. The bundler turns every tag into a call to React.createElement, and that returns an ordinary object:
const element = <h1 className="title">Hello</h1>;
// after the build:
const element = React.createElement("h1", { className: "title" }, "Hello");
// it's an object roughly like this:
// { type: "h1", props: { className: "title", children: "Hello" } }
A tree of such objects is called the virtual DOM. Creating objects is very cheap, while changing the real page is expensive: the browser has to recalculate the layout and redraw pixels. So each time React builds a whole new light tree and makes minimal edits to the real DOM.
Comparing trees
Comparing the old and the new tree is called reconciliation. The rules are simple:
- an element of the same type in the same place — React keeps the DOM node and changes only the attributes and text that changed;
- a different type (
<p>became<div>,<Chat>became<Profile>) — the old node with everything inside is removed, and a new one is created; - in lists, elements are matched by
key, not by order.
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Counter</h1>
<p>Clicked: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
After a click React calls Counter again, gets a new tree and sees: div, h1 and button haven't changed, and the
text of p has. One text node changes in the DOM. If there was a cursor in an input on the page, it won't
disappear: nobody recreated the field.
When a component re-renders
A component is called again when:
- its state has changed (
set…was called with a new value); - its parent re-rendered — then all the children re-render, even if their props didn't change;
- a context it reads has changed (the
useContexttopic).
You can check this by adding console.log right into the component body:
function Child({ label }) {
console.log("render Child", label);
return <p>{label}</p>;
}
function Parent() {
const [n, setN] = useState(0);
console.log("render Parent", n);
return (
<div>
<button onClick={() => setN(n + 1)}>{n}</button>
<Child label="I don't change" />
</div>
);
}
Each click prints both render Parent and render Child. That's normal and almost always fast: rendering is just a
function call, and the DOM for Child doesn't change. How to skip extra renders when it really matters is in the
performance topic.
Hence an important rule: a component must be a "pure" function. With the same props and state it returns the same
JSX and changes nothing outside: it doesn't send requests, doesn't change global variables, doesn't touch the DOM.
React may call it any number of times. Everything "external" goes into event handlers and useEffect.
State is a snapshot
Within one render the state value doesn't change. count is an ordinary constant of the render in which the
handler was created:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // still 0!
}
return <button onClick={handleClick}>{count}</button>;
}
setCount doesn't change the count variable. It tells React: "in the next render give count the value 1". The
new value appears only when React calls Counter again.
Batched updates
If you call several set… in one handler, React doesn't re-render the component after each one: it collects them
into a batch (batching) and does one render after the handler.
function handleClick() {
setCount(count + 1); // count = 0 → "set 1"
setCount(count + 1); // count is still 0 → "set 1"
setCount(count + 1); // "set 1"
}
// result: 1, not 3
If the new value depends on the previous one, pass an updater function. React queues such functions and calls them in order, giving each the result of the previous one:
function handleClick() {
setCount((c) => c + 1); // 0 → 1
setCount((c) => c + 1); // 1 → 2
setCount((c) => c + 1); // 2 → 3
}
The rule: if the next value is computed from the current one — write set((old) => new).
You can't change state directly
How does React know the state has changed? It compares the old and the new value with Object.is (almost the same as
===). For objects and arrays that's a comparison of references, not contents:
const [items, setItems] = useState(["a", "b"]);
function addWrong() {
items.push("c"); // changed the same array
setItems(items); // the same reference → React: "nothing changed" → no render
}
function addRight() {
setItems([...items, "c"]); // a new array → a new reference → a render
}
That's why state in React is treated as immutable: instead of changing it, you create a new copy.
| Action | Array | Object |
|---|---|---|
| Add | [...arr, x] |
{ ...obj, key: value } |
| Remove | arr.filter((x) => x.id !== id) |
const { key, ...rest } = obj |
| Change | arr.map((x) => (x.id === id ? { ...x, done: true } : x)) |
{ ...obj, key: newValue } |
| Sort | [...arr].sort(...) |
— |
The methods push, splice, sort, reverse and the assignment obj.key = … change the original — they aren't used
for state (or you make a copy first).
Remember that a copy with ... is shallow: nested objects must be copied too
({ ...user, address: { ...user.address, city: "Khujand" } }).
Where state lives: the place in the tree
State is stored not "inside the function" but in React — tied to the component's place in the tree. As long as a component of the same type stands in that place, the state is kept, even if the props changed:
{selected === "ann" ? <Chat contact={ann} /> : <Chat contact={timur} />}
For React this is the same <Chat> in the same place — its props simply changed. The message draft typed for Ann
will remain for Timur too. Sometimes that's a bug.
To say "this is a different instance, start from a clean slate", you use key — not only in lists:
<Chat key={contact.id} contact={contact} />
A new key — React removes the old component together with its state and creates a new one. This is the usual way
to reset a form or a widget when the selected item changes.
The opposite is also true: if a component disappeared from the tree (for example, via {show && <Panel />}), its
state is destroyed. When it appears again, it starts with the initial values.
Strict mode
In projects the application is usually wrapped in <React.StrictMode>. In development mode it deliberately calls
components twice and mounts effects twice (run → cleanup → run) to find impure components and effects without
cleanup faster. If you see double console.logs — that's it. There are no double calls in the production build.
Summary
- An update: trigger (
set…) → render (calling components) → commit (minimal DOM edits) → effects. - JSX is objects; React compares the new tree with the old one and changes only the difference.
- A component re-renders when its state changes, when its parent renders and when a context changes. A component is a pure function.
- State is a snapshot of a render; updates are batched. From the old value —
set((old) => ...). - State isn't changed directly: new arrays and objects with
...,map,filter. - State is tied to a place in the tree;
keycreates a new instance and resets the state.