Skip to content
AtomicReps

Render and commit

A lesson from React Under the Hood. Play it above, or read it through below.

  • 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. Which one does the arithmetic?

    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.

    Get the halves backwards and the bug report is unanswerable. Someone reports a stale value that "should have been diffed away", filed against the commit step or against the browser, when nothing in the pass ever produced an operation addressed to it. Paint is not a fourth step. It is the browser's epilogue after React has returned, and react.dev reserves the word "rendering" for React's own phase so the two stop blurring into one event.

    Takeaway: Say which half you mean in the review comment: "it renders too much" and "it writes too much" have different cures, and only one of them is yours to fix.

  • 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 enumerates two reasons for a component to render and the list is closed: 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.

    The second one carries a condition almost nobody has read: 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 hedge the top half of that promise on purpose, because React may still need to call your component before skipping the children. The children half is the guarantee.

    That arrives as "the log I added in the component stopped printing", filed against the logging setup, or against a bundler cache, or against the one teammate who touched the file, on a branch whose only real change was making a setter idempotent. You cannot read a render count off your setter calls, so stop trying to: instrument the body, not the handler, and accept that a queue entry and a render are two different events with a comparison between them.

  • Rendering is React calling your components, all the way down

    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 moves. How far does the render that follows reach?

    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.

    Nothing reaches the DOM while any of it runs, which is why a document.getElementById(...).textContent read from inside a render body returns what the previous commit left there, for the node below the component as much as for the node above it. That is why "the DOM is one render behind" gets filed against a chart library or a stale build, never against the read that produced it.

    What the list does not contain is as useful as what it does:

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

    Takeaway: Equal props buy a child nothing on its own. If you want the descent to stop somewhere, that is a thing you ask for, not a thing you get.

  • Purity is what makes a thrown-away pass free

    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.

    If the ban on side effects in a render body is only a style rule, what is the worst an exception can cost you?

    The rule has a price tag, and it is not 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 sanctioned set-during-render pattern executes the rest of your component function and throws the result away, which react.dev states in those words. Purity is what makes stopping mid-calculation safe.

    Takeaway: Scratch paper shreds for free. An entry in someone else's ledger does not.

    React draws the line 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 free. Stamping 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 an impure line shows its damage doubled. The ticket gets written against StrictMode, the dev server, or whoever added the wrapper last sprint, none of which touched the line. The doubling did not create the bug; it ran an already-shipping bug a second time where somebody could see it. Delete the wrapper and you keep the bug and lose the only thing that was pointing at 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 governs the call people think of as a mount, which is the part that gets shipped 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 sitting inside the root.

    Get that backwards in an interop shim and the ticket reads "the search box empties itself whenever the header changes", filed against the search component, in a pull request that touched neither the search box nor React. If you want a teardown, take a new root; calling render twice is not one and never was.

  • Paint is the browser's epilogue

    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. Is the change on the screen yet?

    The moment commit finishes

    React's last step is not the user's. After rendering is done and React has updated the DOM, the browser repaints the screen; react.dev goes as far as renaming it, calling the thing everyone else calls browser rendering "painting" instead, because "rendering" is already the name of React's own phase and the docs refuse to let the two blur.

    A commit you timed at three milliseconds can still sit in front of a frame the user gets much later, because everything between the two belongs to the browser and to whatever else is occupying the main thread behind you. The kitchen's clock stops at the pass; the food still has to cross the room. That gap is where "the update takes half a second" comes from, filed against React, on a page where the commit finished immediately.

    The three steps end when React returns. If you are going to put a number in the thread, say which step you measured, because "React is slow" is unfalsifiable and what it buys you is a rewrite instead of a fix.