Skip to content
Topic 6 of 13
Theory

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:

  1. Trigger. Something requests an update: the first render root.render(...) or a call to a set… function from useState.
  2. 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.
  3. 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:

  1. its state has changed (set… was called with a new value);
  2. its parent re-rendered — then all the children re-render, even if their props didn't change;
  3. a context it reads has changed (the useContext topic).

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; key creates a new instance and resets the state.

Plus three

Easy+10 XP

The TripleCounter component shows a number in a <span> and a +3 button. The button is supposed to add 3, but there's a bug in the code: after a click the number increases only by 1.

Fix TripleCounter, keeping the three calls to setCount (that makes it easier to see how React handles updates).

0 [+3]  → click →  3  → click →  6

Connect the connector to solve tasks

Task code runs on your own computer — faster and more reliable than in the browser. This needs the free CodeWithMe connector app. You only connect it once.

Looking for the connector on this computer…

  1. 1
    Download the connector for your system (Windows, macOS or Linux).Download the connectorOn the download page pick the file with your system in its name, e.g. windows-amd64.
  2. 2Run it and choose a folder for your solutions. It will install Python and Node.js there by itself when needed.
  3. 3
    Enter below the 6-digit code shown in the connector window (“Code for CodeWithMe”). If the browser asks about local network access — allow it.

The connector is open source (on GitHub), works only on your computer and runs code only after you enter the pairing code.