DEV Community

Cover image for React Fiber without the folklore, Part 2: Lanes, Hooks, and commit
Mohammed Abdelhady
Mohammed Abdelhady

Posted on Edited on Fully Autonomous

React Fiber without the folklore, Part 2: Lanes, Hooks, and commit

Question: Two state updates happen in the same component. Why can React treat one as urgent and let the other wait?

Answer: The Fiber does not store a single "priority." It carries lane bitmasks that let React track several classes of pending work at once.

That answer opens three more questions. Where is Hook state stored? How does completed render work reach the DOM? Why can React prepare a tree without exposing half of it to the screen?

The answers meet inside one Fiber: memoizedState, lanes, flags, and alternate.

This walkthrough uses React 19.2.7 source names. These are implementation details, not public API contracts. The point is to build a debugging model that matches current code, then keep the model loose enough to survive a refactor.

First, kill the single-priority picture

Suppose a search screen updates its input immediately but marks an expensive result calculation as a transition:

import { useState, useTransition } from 'react';

export function SearchBox({ search }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(event) {
    const nextQuery = event.target.value;

    setQuery(nextQuery);

    startTransition(() => {
      setResults(search(nextQuery));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating results...</span>}
      <ResultList items={results} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The two updates are real at the same time, but they do not need the same scheduling treatment. React represents pending work with lanes, which are bits inside an integer mask.

A teaching version might look like this:

const SYNC = 0b0001;
const INPUT = 0b0010;
const DEFAULT = 0b0100;
const TRANSITION = 0b1000;

let pending = DEFAULT | TRANSITION;

const hasTransition = (pending & TRANSITION) !== 0;
const withoutDefault = pending & ~DEFAULT;
Enter fullscreen mode Exit fullscreen mode

Bitmasks make common scheduler questions cheap:

  • Add work: pending | lane
  • Test overlap: (pending & lanes) !== 0
  • Remove completed work: pending & ~finished
  • Pick the least significant set bit: lanes & -lanes

React 19.2.7 defines 31 lanes. The file includes sync, input-continuous, default, transition, retry, idle, offscreen, and deferred groups in ReactFiberLane.js.

Do not memorize the binary constants. They can change. Remember the capability: one Fiber and one root can record multiple pending categories, combine them, and select a subset for a render.

Lanes feeding an alternate Fiber pair and ordered commit phases

Lanes do not mean "run every high bit first"

The scheduler has to consider more than a static ranking. Work can be suspended, pinged when data arrives, expired after waiting, or entangled with related work that must render together.

Even the phrase "higher bit means higher priority" is backwards for the current layout. React's helper for the highest-priority lane takes the least significant set bit:

export function getHighestPriorityLane(lanes) {
  return lanes & -lanes;
}
Enter fullscreen mode Exit fullscreen mode

That implementation appears in ReactFiberLane.js.

The safe public conclusion is not "lane 2 always beats lane 64." It is this: React can preserve urgent interaction while lower-priority rendering remains pending, and APIs such as transitions let application code express that distinction.

Hooks are a positional linked list

Here is the smallest surprising fact in React internals:

A function component's Hook state is not looked up by variable name.

Hooks form a linked list rooted at the Fiber's memoizedState.

const firstHook = {
  memoizedState: 'dark',
  queue: themeUpdates,
  next: secondHook,
};

const secondHook = {
  memoizedState: false,
  queue: menuUpdates,
  next: null,
};

fiber.memoizedState = firstHook;
Enter fullscreen mode Exit fullscreen mode

During mount, React appends each Hook record to that list. During an update, it walks the old and work-in-progress lists in order. You can see both operations in ReactFiberHooks.js.

Now the Rules of Hooks stop sounding ceremonial.

function Panel({ canEdit }) {
  const [open, setOpen] = useState(false); // Hook 1

  if (canEdit) {
    const [draft, setDraft] = useState(''); // Sometimes Hook 2
  }

  const [theme, setTheme] = useState('dark'); // Hook 2 or Hook 3?
}
Enter fullscreen mode Exit fullscreen mode

On one render, theme might consume the second Hook record. On another, draft consumes it first. Names cannot rescue the mismatch because React is advancing linked-list cursors.

This is why Hooks must be called in the same order on every render. Put the condition inside an effect, event handler, or custom Hook. Do not put it around the Hook call.

Queued updates can survive a skipped render

Each state Hook has more than its visible value. It also carries a queue and base state used while React processes updates for the lanes selected in this render.

Imagine two queued updates:

setCount((count) => count + 1);       // urgent lane
startTransition(() => setFilter('x')); // transition lane
Enter fullscreen mode Exit fullscreen mode

If the current render only includes urgent work, React must not lose the transition update. The lower-priority update stays represented for a later pass. The exact queue mechanics are intricate, but the user-visible behavior follows from this requirement: skipping work is not the same as deleting it.

This also explains why reading state immediately after calling its setter still returns the state from the current render. You enqueued work. You did not mutate the closed-over snapshot.

The alternate is a transaction boundary

Part 1 introduced fiber.alternate: the paired current and work-in-progress versions of a Fiber.

During an update, React writes the candidate result into the work-in-progress tree. The currently committed tree remains available. React's source describes this as a double-buffering pool and reuses the alternate after creating it lazily in createWorkInProgress.

Think of it as a transaction boundary, with one caution: it is not a general database transaction and the two trees do not deep-copy every object.

The useful guarantees are narrower:

  • Render work can be prepared away from the visible host tree.
  • An unfinished render does not have to become the screen.
  • A completed result can be committed as one coordinated change.

This is why mutating external systems during render is dangerous. React may call, pause, restart, or abandon render work. A render should calculate JSX from inputs. Side effects belong in event handlers or effects.

Flags are the receipt from render

What does the completed tree need to tell commit?

It needs a compact record of work such as placement, updates, deletion, refs, layout effects, and passive effects. Fibers carry flags for their own work and subtreeFlags summarizing relevant work below them.

A simplified bubble looks like this:

function bubbleFlags(fiber) {
  let subtreeFlags = 0;
  let child = fiber.child;

  while (child) {
    subtreeFlags |= child.flags;
    subtreeFlags |= child.subtreeFlags;
    child = child.sibling;
  }

  fiber.subtreeFlags |= subtreeFlags;
}
Enter fullscreen mode Exit fullscreen mode

The summary lets commit skip clean subtrees instead of inspecting every descendant for every kind of effect.

Older explanations often call this an "effect list." Current React still has Hook effect lists, but DOM commit traversal is better understood through Fiber flags and subtree flags. The vocabulary matters because an old diagram can remain visually convincing after the implementation it describes has moved on.

Commit is several ordered passes

React's public docs say commit is where calculated changes reach the DOM. Internally, "commit" is not one undifferentiated loop.

A practical sequence is:

  1. Before mutation: read information that must be captured before host changes.
  2. Mutation: insert, update, and remove host nodes; detach affected refs.
  3. Layout: attach refs and run layout effects and relevant class lifecycles against the updated host tree.
  4. Passive effects: schedule and later flush passive cleanup and setup such as useEffect.

The work loop explicitly labels the mutation phase as the place where React mutates the host tree, then calls layout effects afterward. See ReactFiberWorkLoop.js.

This ordering answers a common ref question. During render, React has not committed the next DOM. During commit, refs are cleared before affected DOM mutation and set again after the DOM update. React documents that timing in Manipulating the DOM with Refs.

It also separates useLayoutEffect from useEffect:

  • useLayoutEffect runs in the layout part of commit and can read the updated layout before the browser paints, but heavy work there delays paint.
  • useEffect is passive. It normally runs after the committed screen is available, with timing details that React may optimize around interactions.

Use layout effects for measurements that must block paint. Use passive effects for synchronization that does not.

Trace one update end to end

Take the search example from the opening.

  1. The input event queues an urgent query update.
  2. The transition queues result work in transition lanes.
  3. The root records both categories as pending.
  4. React selects lanes for a render.
  5. beginWork processes Fibers whose own lanes or descendant lanes overlap the selected work.
  6. Function components walk their Hook lists and process eligible queued updates.
  7. Completion bubbles flags and remaining child lanes.
  8. If the render finishes and is accepted, commit applies the flagged host work in ordered phases.
  9. Work in lanes not included in that render remains pending.

Animated trace from lane selection through mutation, layout, and passive effects

That is the relationship among the pieces. Lanes answer "which work now?" Hook queues answer "which state transitions belong to that work?" Alternates answer "where can we prepare it?" Flags answer "what must commit do?"

The model I keep beside the profiler

When an update behaves oddly, I do not start with "React re-rendered everything." I ask:

  • Which Fiber kept or lost identity?
  • Which lanes were pending, selected, suspended, or left behind?
  • Which Hook position and queue produced this state?
  • Which flags survived into commit?
  • Did the suspicious code run in render, mutation, layout, or a passive effect?

Those questions are specific enough to guide a profile, but they do not pretend internal constants are permanent.

Fiber is not one trick. It is the meeting point between identity, resumable traversal, update queues, scheduling, and host mutation. The architecture becomes useful when you stop treating those words as separate chapters and trace one update through all of them.

Top comments (0)