Skip to content
AtomicReps

Render and commit

A lesson from How React Works. Play it above, or read it through below.

Level I · The Rendering Model
  • Render computes the change list, commit applies it

    The common read: Commit is the diffing step, so React walks its tree against the live DOM while committing, finds what differs, and patches it.

    An update runs three steps: trigger, render and commit.

    By the time commit begins, the list of DOM operations

    Commit compares nothing. Each step has exactly one job:

    Trigger: the state update that starts it. Render: React calls your components and calculates what changed since the previous pass. Commit: React applies that result, changing a DOM node only where the two passes differ.

    If you confuse render and commit, you cannot answer some bug reports correctly. For example, a label still shows an old value after an update. The engineer blames commit or the browser for not writing it, but the render pass never produced a write for that node, so the fix is in what the component returned.

    Paint is not a fourth step. The browser paints after React has returned, and react.dev reserves the word "rendering" for React's own phase so that readers do not confuse the two.

    Takeaway: Say which half you mean in the review comment. "It renders too much" means React calls too many components. "It writes too much" means your output changed where it did not need to, for example a key that changes on every update. The two problems have different fixes.

  • A setter is not a trigger

    The common read: Calling a state setter always starts a render, so passing the value the component already holds costs one wasted pass whose output happens to match.

    A change is what starts a render; the setter only asks for one. react.dev's render-and-commit page gives two reasons for a component to render: the component's initial render, which is createRoot(node) followed by root.render(<App />), and a state update on that component or on one of its ancestors. A component that reads a context also renders when the provider's value changes.

    The state update has a condition that many readers miss: if the value you pass is Object.is-equal to the current state, React skips re-rendering that component and its children, and the docs qualify the first half of that promise on purpose, because React may still need to call your component before skipping the children. Only the skip for the children is guaranteed.

    For example, a developer reports "the log I added in the component stopped printing" and blames the logging setup, a bundler cache, or a teammate, on a branch whose only change is that a setter now passes the value the state already holds, so React skips the render. A log in the handler counts setter calls; a log in the component body counts renders. Treat a queue entry and a render as two separate events with a comparison between them.

  • Rendering is React calling your components and every component they return

    The common read: A state update re-renders the component that owns the state, so any other component that re-ran did so because something of its own changed too.

    One component's state changes.

    A child whose props did not change

    React does not stop at the component you updated. Rendering is React calling your components, and react.dev says the call is recursive: it starts at the function component whose state update triggered the render, then calls whatever that component returned, then whatever that one returned, until there are no more nested components left.

    React writes nothing to the DOM while these calls run, so a document.getElementById(...).textContent read from inside a render body returns what the previous commit wrote, for the node below the component as much as for the node above it. As a result, a bug described as "the DOM is one render behind" gets filed against a chart library or a stale build, never against the read that caused it.

    That recursive sequence of calls also tells you what React does not call:

    An ancestor is not called again. A sibling subtree nobody re-returned is not visited.

    Takeaway: Equal props alone do not stop React from calling a child. If you want the descent to stop at a component, you have to ask for that explicitly; React does not do it by default.

  • Purity makes it safe for React to discard a render pass

    The common read: Banning side effects in the render body is a style rule, and the worst an exception costs you is a lint warning.

    Breaking the rule against side effects in a render body costs more than a lint warning. React treats your render body as a calculation it may run more than once for a single update, or abandon halfway through without telling you. You have already seen one case: the documented set-during-render pattern executes the rest of your component function and throws the result away, and react.dev documents that behaviour. Purity makes it safe to stop in the middle of a calculation.

    Takeaway: React can discard work that only changed values created during the render. It cannot undo a write to something that existed before the render.

    React's rule is narrower and more useful than "no side effects": a component "should not change any objects or variables that existed before rendering." Sorting a copy of the caller's array is safe. Writing a field onto the caller's row objects is not.

    Wrap the tree in StrictMode and development builds call each component's function twice, so the effect of an impure line happens twice. The bug report is then filed against StrictMode, the dev server, or whoever added the wrapper in the last sprint, and none of those touched the line.

    The doubling did not create the bug. The impure line also runs in production, once per render, where its damage is harder to notice. StrictMode runs it twice in development so that the damage is visible. If you delete the wrapper, the bug stays and you lose the only thing that was showing it.

  • Commit builds once, then patches

    The common read: Commit reapplies the tree the pass returned, so whatever the browser was holding inside those nodes (scroll position, focus, a half-typed field) is rebuilt along with them.

    Commit does not reapply the tree. On the very first commit React creates the DOM nodes and uses appendChild to put them on screen, and on every commit after that it applies the minimal operations the pass already calculated, changing a DOM node only where there is a difference between renders.

    A position whose output matched the previous pass yields no operation at all, so the node that was there stays there, and everything the browser was keeping inside it stays with it: the scroll offset, the focus ring, the text a user typed into an uncontrolled input while a sibling re-rendered every second.

    The same rule applies to the call people think of as a mount, and developers get this part wrong: a second root.render() on the same root does not clear the container; it matches against the tree already rendered the way a state update would, and only the FIRST call clears the HTML that was inside the root.

    If an interop shim assumes the opposite, a user reports "the search box empties itself whenever the header changes", and the bug is filed against the search component, in a pull request that touched neither the search box nor React. If you want to clear the container, call root.unmount() and then create a new root. A second render call does not clear it.

  • Paint is a browser step after React finishes

    The common read: Commit finishing is the moment the user sees the change, so the time React spends in commit is the latency the user feels.

    Commit has finished and React has returned.

    The moment commit finishes

    After React has updated the DOM, the browser repaints the screen; react.dev calls that step "painting" to keep it apart from React's own "rendering" phase. A commit timed at three milliseconds can come long before the frame the user sees, because the browser and other main-thread work run in between. That gap produces reports like "the update takes half a second", filed against React on a page where commit finished immediately.

    When you post a number, say which step you measured: nobody can test "React is slow", and it leads to a rewrite instead of a fix.