← back to the section

You change state and a new number appears on the screen. Between those two events React does work that most developers never think about until it gets in the way: a list jumps when an item is deleted, a component re-renders twenty times, console.log prints twice, the value after setState is still the old one. All of these oddities are explained by one model. Let us walk through it step by step, from the component function to the real DOM.

The component as a pure function

A function component is a function from props and state to a description of the UI. React expects it to be pure in the mathematical sense: the same inputs give the same output and there are no side effects inside. A request to the server, a write to localStorage or a change to an outside variable right in the component body break the rule; effects and event handlers exist for those.

Purity is not for beauty. React reserves the right to call the component as many times as it wants and at any moment: to compare the result with the previous one, to prepare a render in advance, to interrupt it and start over. StrictMode in development deliberately calls every component twice, and if the second call breaks something, the component is not pure. The immutability rule comes from the same place: state is not mutated but replaced with a new object, otherwise React cannot see that it changed.

Virtual DOM: a description, not the screen

The component function returns not DOM nodes but lightweight description objects: an element of this type with these props and these children. JSX is syntax for creating them, which Babel or TypeScript turns into function calls. A tree of such descriptions is what people call the Virtual DOM.

Why the extra layer? Changing the real DOM is expensive: every change may force the browser to recompute layout. Creating JavaScript objects is cheap. So React builds the new description in full, compares it with the old one and applies only the difference to the DOM. It is important to understand that "the Virtual DOM is faster than the DOM" is a wrong sentence: a direct targeted DOM change is always faster than comparing trees plus the same change. What the Virtual DOM buys is not speed but the ability to write a component as a pure function without thinking about what exactly changed.

Reconciliation: how React compares trees

Comparing two trees in the general case costs cubic time, and React replaces it with a heuristic of two rules. First: if the element type changed, a div became a span or one component became another, the whole subtree is thrown away and rebuilt together with its state. Second: children are compared by position, and in lists by key.

Hence the role of key. Without keys React matches list items by index: delete the first item and React decides that the first item changed its content and the last one disappeared, updating every node in turn. With keys it understands that the item with that key simply vanished and the rest stayed in place, and moves the nodes. The array index as a key works exactly until the first reorder or deletion: input state, animations and focus jump to the wrong item.

const items = [{ id: 7, name: "Tea" }, { id: 3, name: "Coffee" }];
const good = items.map((item) => <li key={item.id}>{item.name}</li>);
const bad = items.map((item, index) => <li key={index}>{item.name}</li>);

Changing the key of one element is the command "treat it as new": state resets, effects run again. This is used on purpose to reset a form when the record changes.

Two phases: render and commit

React's work is split into two phases. In the render phase it calls components and builds the new tree, in the commit phase it applies changes to the DOM and runs effects. The render phase is pure and can be interrupted or discarded; the commit phase is synchronous and indivisible: the user never sees a half-updated screen.

Effects are laid out across the phases too. useLayoutEffect runs synchronously right after the DOM change, before the browser paints a frame: that is where you measure sizes and move elements without flicker. useEffect runs after paint without delaying the frame: requests, subscriptions, logging. The class lifecycle methods map onto the same scheme: componentDidMount and componentDidUpdate are an effect, componentWillUnmount is its cleanup function.

Fiber: work that can be interrupted

Before 2017 rendering was a recursive call that could not stop halfway: a large tree blocked the thread and user input waited. Fiber is the rewritten core where the component tree is turned into a linked list of units of work. Each unit, a fiber, is one component with its props, state and a reference to its DOM node, and React processes them one at a time, checking between them whether it is time to yield the thread to the browser.

The React 18 features grew out of this: concurrent rendering, where an urgent update such as typing interrupts a non-urgent one such as filtering a long list; useTransition and useDeferredValue, which mark an update as non-urgent; Suspense, which shows a fallback while data or code is loading. All of them rely on the render phase being pure and discardable without consequences.

What triggers a re-render

A component re-renders in three cases: its own state changed, its parent re-rendered, or the value of a context it reads changed. Props by themselves are not a trigger: React does not compare them by default, it simply calls child components after the parent. That is why wrapping a parent in a container component re-renders the whole tree beneath it, and a context re-renders all its subscribers at once even if they need one field out of ten.

React.memo adds a props comparison: the component skips rendering if no prop changed by reference. This works as long as props are stable: an object or function created in the parent's body is new every time, and memo will not save it without useMemo and useCallback. Hence the rule from the performance article: profiler first, then targeted memoization, not memo on everything.

Batching and the "asynchronous" setState

setState does not change state in place: it queues an update and marks the component for re-rendering. So the line after setCount(count + 1) sees the old value, and two setCount(count + 1) calls in a row increase the counter by one. To rely on the previous value, pass a function: setCount((c) => c + 1).

Several updates in one handler are merged by React into a single re-render; this is batching. Since React 18 it works everywhere: in event handlers, in promises, in timers. So three setState calls in a row produce one render, and arguing about their order is pointless. If an update must be applied synchronously, for example to measure the DOM right after, there is flushSync, and it is a rare tool.

In short

  • A component is a pure function of props and state; React may call it any number of times, and StrictMode calls it twice in development.
  • The Virtual DOM is a tree of descriptions whose difference React applies to the DOM; it buys not speed but the "describe, don't mutate" model.
  • Reconciliation: a different element type means rebuilding the subtree; children are compared by position, in lists by key, and an index as a key breaks on reordering.
  • Two phases: render is pure and interruptible, commit is synchronous; useLayoutEffect before the frame, useEffect after it.
  • Fiber split rendering into interruptible units of work; concurrent rendering, useTransition, useDeferredValue and Suspense stand on it.
  • Re-renders are caused by own state, the parent and context; memo compares props by reference and is useless without stable props.
  • setState queues an update and updates are batched into one render; to depend on the previous value, pass a function.

Further reading