Fiber, lanes and concurrent rendering

React · Expert · 20 min

Concurrent features, Strict Mode's rules and many subtle bugs only make sense once you know how React schedules and discards work.

Internally, React represents your component tree as a linked tree of fibers: one object per component instance or host element, holding its type, key, props, the list of Hooks (its state), pointers to child, sibling and return (parent), and flags describing pending DOM changes. Hooks state lives on the fiber, which is why Hooks are matched by call order.

React keeps two trees: the current tree (what's on screen) and a work-in-progress tree being built from it (double buffering). Rendering walks the work-in-progress tree one fiber at a time: beginWork calls the component and reconciles children, completeWork prepares host nodes and bubbles flags up. Because work is a loop over fibers rather than recursion on the call stack, React can pause between fibers, yield to the browser (the scheduler works in slices of a few milliseconds), and resume, or throw the work-in-progress away.

The commit phase is different: it's synchronous and can't be interrupted. React applies DOM mutations, attaches refs, runs useLayoutEffect and swaps the trees, so users never see a half-updated UI. Passive effects (useEffect) run afterwards.

Updates are assigned lanes, priority bits in a bitmask: discrete input (clicks, key presses) gets a sync lane, continuous events (scroll, mousemove) a higher-than-default lane, ordinary updates the default lane, startTransition updates transition lanes, and offscreen or idle work the lowest. A render processes a set of lanes; when a higher-priority update arrives during a transition render, React interrupts, handles the urgent lane, and later restarts the transition.

Consequences for your code: a render may run many times or never commit, so render must be pure and side effects belong in effects; reading mutable external data during render can produce tearing (different parts of one commit seeing different values), which is what useSyncExternalStore prevents. Sync updates are not interruptible, which is exactly why transitions exist.

Example

import { useLayoutEffect, useEffect, useState, startTransition } from "react";

// Logs illustrate the render/commit/effects ordering.
function Child({ value }) {
  console.log("render Child", value);           // may run without committing
  useLayoutEffect(() => console.log("layout effect", value)); // commit phase, sync
  useEffect(() => console.log("passive effect", value));      // after paint
  return <p>{value}</p>;
}

export default function App() {
  const [urgent, setUrgent] = useState("");
  const [slow, setSlow] = useState("");

  function onChange(e) {
    setUrgent(e.target.value);                     // sync lane: never interrupted
    startTransition(() => setSlow(e.target.value)); // transition lane: may restart
  }

  return (
    <>
      <input value={urgent} onChange={onChange} />
      <Child value={slow} />
    </>
  );
}
// Typing fast: several "render Child" logs may appear for values that are
// never committed - their transition renders were interrupted and discarded.

Remember

  • Each component instance is a fiber; Hook state lives on it as an ordered list
  • Render builds a work-in-progress tree in interruptible units; commit is synchronous and atomic
  • Lanes prioritise updates: input is sync, transitions are interruptible and can restart

Watch out for

  • Assuming every render is followed by a commit, e.g. counting renders or logging analytics in the component body.

Check yourself

  1. Why must rendering be free of side effects in concurrent React?
  2. Which part of an update can React interrupt to handle a more urgent update?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/blog/2022/03/29/react-v18