Skip to main content
Dissecting React's Internal Architecture: A Comprehensive Guide to JSX, and React Fiber
By Zuhayr Barhoumi

Table of Contents

Introduction: Why I Dove Into React’s Internals

Part 1: JSX Transpilation - From Function Calls to Syntax Sugar
Part 2: React Reconciliation - The Craft of Efficient Updates
Part 3: The Stack Reconciler - Understanding the Old Model's Drawbacks
Part 4: React Fiber Architecture - The Modern Engine
Part 5: Priorities and Scheduling - Making Wise Updates
Part 6: Concurrent React - startTransition, useDeferredValue, and Suspense
Part 7: Innovations in React 19.2 - Activity, useEffectEvent, and More
Part 8: Applying Knowledge to Real Projects

Final Thoughts & Challenge


[react fiber architecture]

Introduction: Why I Dove Into React’s Internals

You don’t need to understand React’s internals to build good applications. That’s especially true today, with AI-assisted coding handling much of the boilerplate and iteration. React’s value proposition has always been abstraction: hide complexity so developers can focus on shipping features.That assumption worked for me—until it didn’t.

As an indie developer working on full-stack projects, I kept running into problems that abstraction alone couldn’t solve. Memoization didn’t fix performance bottlenecks. Bugs came out of nowhere and felt non-deterministic. Architectural decisions were recommended, but I couldn’t clearly grasp why one approach was better than another. I was following best practices, but blindly.

Coming from Vue, with its explicit reactivity system, I eventually realized the issue wasn’t React itself. It was my mental model. I knew what to do: use hooks, lift state, memoize expensive re-renders. What I didn’t know was why these techniques worked, or when they were unnecessary. That gap became increasingly uncomfortable as I started aiming for more senior, architecture-focused roles.

So I decided to stop treating React as a black box.

Instead of just reading the docs, I cloned the React repo, and and traced the full path from JSX to DOM updates, following how state changes propagate through the Fiber architecture.

It was slow at first. Dense. But eventually led to clarity.”Boxes of Orden” style. Each piece of knowledge unlocked the next until the entire system became clear.

I wrote this article as the comprehensive guide I wish I’d read:

  • We’ll begin with JSX transpilation (how your declarative code becomes function calls)
  • go through React’s reconciliation process (the “diffing” magic);
  • delve deeply into Fiber architecture (the engine that makes everything async and interruptible)
  • and examine the most recent advancements in React 19.2.

Who this is for: This is intended for developers who are familiar with the fundamentals of React and who wish to gain a better understanding of the system. If you’ve been wondering why hooks can’t be conditional, how startTransition works internally, or what “reconciliation” actually means. You’ve come to the right place.

Let’s dissect React together.

Part 1 - JSX Transpilation: From Function Calls to Syntax Sugar:

Understanding JSX as a DSL for Tree-Building

Writing JSX seems like magic at first. You type what appears to be HTML:

const App = () => {
  return (
    <div draggable>
      <h2>Hello React!</h2>
      <p>I am a paragraph</p>
      <input type="text" />
    </div>
  );
};

And in some manner, the user interface becomes interactive. That is despite the fact that JSX is not understood by browsers. What’s going on, then?

JSX is not HTML. It compiles to JavaScript function calls and is considered a Domain Specific Language (DSL). It is transformed through the build process by tools such as Babel. Let’s actually see the transformation:

// What you write (JSX):
const element = <h1>Hello, JSX!</h1>;

// What Babel outputs (npm run build with babel):
const element = React.createElement('h1', null, 'Hello, JSX!');

A simple JavaScript object, or React element, is created by that React.createElement call:

const element = {
  type: "h1",
  props: {
    children: "Hello, JSX!",
  },
  key: null,
  ref: null,
  $$typeof: Symbol.for("react.element"), // Internal React marker
};

Building the Element Tree with the createElement Function

To see how nested JSX compiles, let’s follow the example we saw earlier:

const App = (
  <div draggable>
    <h2>Hello React!</h2>
    <p>I am a paragraph</p>
    <input type="text" />
  </div>
);

This is changed by Babel into:

const App = React.createElement(
  "div",
  { draggable: true },
  React.createElement("h2", null, "Hello React!"),
  React.createElement("p", null, "I am a paragraph"),
  React.createElement("input", { type: "text" }),
);

Key observations:

  • React creates the tree bottom-up (children before parents), hence top-level items are called last
  • The second argument is props; attributes like draggable become object properties.
  • Children are variadic arguments; a child node is anything that comes after props.
  • “Hello React!” is simply a string child; text nodes are strings.

Implementing createElement was surprisingly easy:

const React = {
  createElement: (tag, props, ...children) => {
    // If tag is a function (component), call it to get elements
    if (typeof tag === "function") {
      return tag(props, ...children);
    }

    // Otherwise, create an element object
    return {
      tag,
      props: props || {},
      children,
    };
  },
};

This generates our Virtual DOM tree, which is an efficient JavaScript representation of the UI structure for React.

Instances, React Components, and React Elements

I was first confused by this distinction, so let’s clarify that:

  • React Element: A plain object that describes the content to be rendered ({ type: ‘div’, props: {…} }) - React Component: A class or function that returns elements
  • Component Instance: For class components, the actual class instance React maintains
// Element (plain object)
const element = <h1>Hello</h1>;

// Function component (returns elements)
const Greeting = ({ name }) => <h1>Hello {name}</h1>;

// Class component (instance maintained by React)
class Greeting extends React.Component {
  render() {
    return <h1>Hello {this.props.name}</h1>;
  }
}

Why this matters for performance: The cost of creating elements (simply objects) is minimal. Thousands can be produced with React with little cost. However, reconciling them to actual DOM nodes is costly, which leads to the following section.

Setting up Babel for JSX

Here’s how JSX transformation operates if you’re interested in the tooling setup.

Configuring .babelrc:

{
  "presets": ["@babel/preset-react"]
}

This instructs Babel to use the react preset which includes the JSX transform plugin. Additionally, you can choose which function to call:

/** @jsx myCreateElement */
// This comment tells Babel to use a custom function

const element = <div>Hello</div>;
// Becomes: myCreateElement('div', null, 'Hello')

React 17+ automatic JSX transform: Modern React uses a new transform that doesn’t require importing React:

// Old (React 16): Had to import React
import React from "react";
const element = <div>Hello</div>;

// New (React 17+): Automatic import
// No React import needed!
const element = <div>Hello</div>;
// Babel imports jsx() from 'react/jsx-runtime' automatically

For this reason, generated output might include jsx() or jsxs() rather than React.createElement().

Creating Your Own JSX Transpiler (In-Depth Bonus)

Want to understand JSX compilation in depth? You can create a Babel plugin. Here’s a simplified version I tried:

module.exports = function (babel) {
  const { types: t } = babel;

  return {
    name: "custom-jsx-plugin",
    visitor: {
      JSXElement(path) {
        // Get opening element (<div>, <h1>, etc.)
        const openingElement = path.node.openingElement;
        const tagName = openingElement.name.name;

        // Build arguments for createElement
        const args = [];
        args.push(t.stringLiteral(tagName)); // "div"
        args.push(t.nullLiteral()); // props (null for simplicity)

        // Create React.createElement call
        const reactIdentifier = t.identifier("React");
        const createElementIdentifier = t.identifier("createElement");
        const callee = t.memberExpression(
          reactIdentifier,
          createElementIdentifier,
        );

        // Convert children
        const children = path.node.children
          .map((child) => {
            if (t.isJSXText(child)) {
              const text = child.value.trim();
              return text ? t.stringLiteral(text) : null;
            }
            return child;
          })
          .filter(Boolean);

        const callExpression = t.callExpression(callee, args.concat(children));

        // Replace JSX with call expression
        path.replaceWith(callExpression);
      },
    },
  };
};

This plugin:

  • Locates every JSXElement node within the Abstract Syntax Tree (AST)
  • Extracts the tag name and constructs a React.createElement call
  • Replaces the JSX node with the function call

Running Babel with this plugin transforms JSX to JS. Just add this to your .babelrc:

{
  "plugins": ["./custom-jsx-plugin.js"]
}

This tells Babel to load your plugin when it runs.

You can see the AST representation of your JSX for yourself with AST Explorer. It’s really informative.

Key Points: JSX and Element Trees

  • JSX is syntactic sugar for React.createElement() calls
  • Elements are plain, inexpensive objects that describe the UI structure
  • Your app’s blueprint the element tree, which React reconciles to the real DOM
  • Functions are return element trees are called components
  • Knowing how Babel handles transformation helps in troubleshooting build problems

Now that we know how JSX generates element objects, let’s examine how React uses reconciliation to turn these objects into actual user interface.

Part 2 - React Reconciliation: The Craft of Effective Updates

What is Reconciliation?

Reconciliation is React’s approach of taking your element tree (the declarative description of UI) and making it the truth in a host environment (browser DOM, React Native views, terminal CLI, etc.).

When you trigger a state update or call ReactDOM.render(, container), React needs to:

  1. Compare the new element tree with the previous one
  2. Identify the changes (diffing)
  3. Update only what’s necessary in the actual DOM

This is the main selling point of React: reliable, effective updates without having to manually modify DOM nodes.

The Mental Model: UI as a Function of State

React’s philosophy is declarative:

UI = f(state);

You describe what the UI should look like for a given state, and React figures out how to get there. Contrast this with imperative approaches (jQuery, vanilla JS):

// Imperative (you tell the browser HOW to update)
if (newCount !== oldCount) {
  document.getElementById("count").textContent = newCount;
}
// Declarative (you describe WHAT it should be)
<span id="count">{count}</span>;

React handles the “how” through reconciliation.

The naive approach: Why don’t we just rebuild everything?

The initial instinct might be: “Why not throw away the old DOM tree and create a new one on every state change” Simple, right?

Issues with this strategy:

  1. Lost UI state: Form values, input focus, and scroll positions are all gone.
  2. Expensive: Creating DOM nodes is slow, and it is wasteful to delete and recreate them.
  3. Bad UX: Flickers, missing animations, and interrupted user interactions.

All of this is solved by React’s reconciliation, which updates only what has changed while intelligently reusing DOM nodes.

Tree Diffing: The O(n³) Problem

Finding the least amount of change between two trees is a well-known computer science problem. The best algorithm is O(n³), which equates to 1 billion operations for a tree with 1000 nodes. Impractical for rendering at 60 frames per second (~16 ms per frame).

In order to achieve O(n) diffing, React makes two key assumptions:

  1. Different trees are produced by different types of elements: A <div> will never become a <span> while keeping children. If the root type changes, throw away the subtree and rebuild.
  2. Stable keys indicate stable elements: React uses keys in lists to identify which elements are “the same” between renders.

These techniques/heuristics sacrifice the “perfect” solutions in exchange for reliable, and fast performance.

Single-Pass Diffing Algorithm

This is the pseudocode for the reconciliation flow:

function reconcile(oldElement, newElement):
	// Rule 1: Different types → full rebuild
	if oldElement.type !== newElement.type: destroySubtree(oldElement) createNewSubtree(newElement) return
	// Rule 2: Same type → update props, recurse on children
	if oldElement.type === newElement.type: updateNodeProps(oldElement, newElement) reconcileChildren(oldElement.children, newElement.children)

For children reconciliation:

function reconcileChildren(oldChildren, newChildren):
	for each child in max(oldChildren.length, newChildren.length):
		oldChild = oldChildren[i]
		newChild = newChildren[i]

	if both exist and have same key:
		reconcile(oldChild, newChild) // Update in place
	else if only newChild exists:
		createNode(newChild) // Insert new node
	else if only oldChild exists:
		destroyNode(oldChild) // Remove old node

The Importance of Keys in Lists

Keys are React’s way of tracking element identity across renders. Without keys, React uses index-based reconciliation, which causes bugs:

// Bad: Using array indices as keys
{
  items.map((item, index) => <TodoItem key={index} {...item} />);
}

What goes wrong: If you delete the first item, React thinks:

  • Item at index 0 changed from “Buy milk” to “Walk dog”
  • Item at index 1 changed from “Walk dog” to “Call mom”
  • Item at index 2 was removed As a result, rather than just deleting one item, React updates all of them. Even worse, items with internal state (such as input focus) become mismatched.
// Good: Stable, unique keys
{
  items.map((item) => <TodoItem key={item.id} {...item} />);
}

React is now aware that the item with id: 1 has been removed, but the other items stayed the same.

Bailout Optimizations: PureComponent and React.memo

If props haven’t changed, React can skip reconciliation:

// Function component optimization
const ExpensiveComponent = React.memo(({ data }) => {
  console.log("Rendering ExpensiveComponent");
  return <div>{expensiveComputation(data)}</div>;
});

// Class component optimization
class ExpensiveComponent extends React.PureComponent {
  render() {
    console.log("Rendering ExpensiveComponent");
    return <div>{expensiveComputation(this.props.data)}</div>;
  }
}

How it works: React compares props using shallow equality (Object.is):

function shallowEqual(objA, objB) {
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) return false;

  for (let key of keysA) {
    if (!Object.is(objA[key], objB[key])) return false;
  }

  return true;
}

Gotcha: Shallow equality means objects and arrays need stable references:

// Anti-pattern: New object every render
<ExpensiveComponent data={{ name: user.name }} />;

// Fixed: Memoize the object
const userData = useMemo(() => ({ name: user.name }), [user.name]);
<ExpensiveComponent data={userData} />;

Mount vs Update Phases

Initial mount and updates are handled differently in React:

Mount (first rendering):

  • Create Fiber nodes for entire tree
  • Create DOM nodes
  • Attach event listeners
  • Insert into container
  • Run useLayoutEffect / componentDidMount
  • Run useEffect callbacks

Update (state/props change):

  • Build a new element tree
  • Reconcile with current Fiber tree
  • Mark changed Fiber nodes with effect tags
  • Commit phase: apply DOM changes
  • Run cleanup for changed effects
  • Run new effect callbacks

Understanding this split helps you debug issues like “Why doesn’t my effect run on mount?” (you forgot to include it) or “Why does this run on every render?” (dependency array issue).

Reconciliation Example: Step by step

Let’s follow a real-world example. To begin with:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

Initial render (mount):

  1. React creates Fiber node for Counter
  2. Calls Counter() function → returns element tree
  3. Creates Fiber nodes: divh1 → text, button → text
  4. Creates DOM nodes: <div>, <h1>, text node “Count: 0”, <button>, text node ”+”
  5. Inserts into container

After clicking button (update):

  1. setCount(1) schedules update
  2. React calls Counter() again → new element tree
  3. Reconciliation begins at root div:
    • Type: divdiv ✓ (same, update props)
    • Props: {}{} ✓ (unchanged, skip)
    • Children: Recurse
  4. Reconcile h1:
    • Type: h1h1
    • Props: {}{}
    • Children: text “Count: 0” → “Count: 1” ✗ (changed!)
    • Mark for update
  5. Reconcile button:
    • Type: buttonbutton
    • Props: { onClick: ... }{ onClick: ... } ✓ (same reference)
    • Children: text ”+” → ”+” ✓
    • Skip update
  6. Commit phase: Update only the text node in h1

Instead of rebuilding the entire tree, React just made changes to one text node.

Important Lessons: Reconciliation

  • React’s core algorithm for reconciliation is diffing + applying changes.
  • Heuristics-based O(n) complexity—practical over theoretical
  • Keys as identifiers are crucial for list performance
  • Bailouts are made possible by shallow prop comparison, and unnecessary work is avoided by memorization
  • Lifecycle methods and hooks represent the differences between mount and update

However, this is where my initial confusion grew: How does React manage big trees without obstructing the UI? It can take hundreds of milliseconds to recursively reconcile 10,000 components. That’s where the Stack Reconciler reached its limits, and Fiber was created.

Part 3 - The Stack Reconciler: Understanding the Drawbacks of the Old Model

How the Stack Reconciler Worked

Before React 16 (pre-Fiber), React used a synchronous, recursive reconciliation algorithm. I call it the “Stack Reconciler” because it relied on JavaScript’s call stack:

function reconcileNode(node) {
  // Update this node
  updateProps(node);

  // Recursively process children
  node.children.forEach((child) => {
    reconcileNode(child); // This goes on the call stack
  });

  // Complete work for this node
  completeWork(node);
}

For tiny trees, this worked perfectly, but for huge programs, it had fundamental problems.

The Blocking Nature of Recursion

JavaScript is single-threaded. When you call a function, it goes on the call stack: [call stack blocked]

The problem: You can’t pause recursion mid-flight. Once reconciliation starts, it runs to completion. If it takes 500ms, the main thread is blocked for 500ms.

During this time:

  • User clicks don’t respond
  • Input typing lags
  • Animations stutter
  • Browser can’t paint

[pre fiber reconciliation diagram]

This is what we mean by the infamous phrase “blocking the main thread.”

The 16ms Frame Budget

To achieve 60 frames per second (fps), each frame must complete in:

1000ms / 60fps = 16.67ms per frame

However, the browser needs time for housekeeping (layout, paint, garbage collection), so your JavaScript budget is closer to 10ms per frame.

If React spends 50ms reconciling, that’s 3 dropped frames. Users perceive this as “jank”—stuttery, unresponsive UI.

Why Recursion Can’t Be Interrupted

The JavaScript call stack is a synchronous data structure. Consider this recursive function:

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

fibonacci(10);

Call stack during execution:

fibonacci(10)
  fibonacci(9)
    fibonacci(8)
      fibonacci(7)
        ... (keeps growing)

You can’t pause at fibonacci(7), handle to a user click, and then continue. The stack has to fully unwind.

The same issue existed with React’s old reconciler. Once you started reconciling a tree, you were committed. No pausing for high-priority updates (such as user input).

The setState Batching Band-Aid

React attempted to use automatic batching to reduce blocking:

handleClick = () => {
  this.setState({ count: 1 });
  this.setState({ flag: true }); // Batched into single re-render
};

Inside React event handlers, multiple setState calls were batched. However, this was ineffective for:

  • Large component trees
  • Expensive render functions
  • Updates outside React event handlers (setTimeout, promises)

Updates in async contexts were not batched prior to React 18:

setTimeout(() => {
  this.setState({ count: 1 }); // Render #1
  this.setState({ flag: true }); // Render #2 (separate!)
}, 1000);

Real-World Scenario: The Freezing Dashboard

I built a dashboard with 500+ rows in a data table. Each row was a component with checkboxes, dropdowns, and action buttons. Filtering the table triggered a re-render of the entire list.

The main thread was blocked for 800 milliseconds. Clicking “Select All” required the user to wait almost a second for the checkbox to react. Unacceptable.

I tried:

  • Virtualization (react-window): Helped, but didn’t eliminate the problem
  • Memoization everywhere (React.memo): Reduced re-renders but didn’t make them interruptible
  • Debouncing: Just delayed the freeze

The core issue remained: reconciliation was synchronous and uninterruptible.

The Need for Concurrency

What I needed:

  1. Pausable rendering: React could reconcile part of the tree, yield to browser, continue later
  2. Priority-based updates: User input (click, type) should interrupt low-priority work (data table update)
  3. Async coordination: Multiple updates could be “in flight” without committing half-finished work

This required a fundamental architecture change. Enter: React Fiber.

Comparing Stack vs Fiber Reconcilers

AspectStack ReconcilerFiber Reconciler
AlgorithmSynchronous recursionAsynchronous, pausable work loop
InterruptibilityNone—all or nothingCan pause at any Fiber node
PrioritizationNone—FIFO queuePriority lanes (urgent → idle)
Data StructureCall stack (implicit)Linked list of Fiber nodes
ConcurrencyNoYes (React 18+)
Work ReuseLimitedCan reuse/abort work-in-progress trees

The Fiber architecture completely redesigned the old algorithm rather than only optimizing it.

Part 4 - React Fiber Architecture: The Modern React Engine

What Is a Fiber?

React’s core work unit is called a Fiber. It is an object in JavaScript that represents:

  • A component instance (for function/class components)
  • A DOM node (for host components like <div>)
  • Metadata about rendering (props, state, effects, priority)

Think of Fiber as a “virtual stack frame”— a call stack function that is under your control. You are able to:

  • Pause work on a Fiber (save its state)
  • Resume later (pick up where you left off)
  • Discard entirely (abort unnecessary work)
  • Reorder (high-priority work first)

Fiber Node Structure

Here’s the actual structure (simplified from React source):

type Fiber = {
  // Instance identification
  tag: WorkTag; // FunctionComponent, ClassComponent, HostComponent, etc.
  type: any; // Function/class for components, "div"/"span" for host
  key: string | null; // Key from props

  // Tree structure (singly-linked list)
  child: Fiber | null; // First child
  sibling: Fiber | null; // Next sibling
  return: Fiber | null; // Parent (called "return" as in "return to parent")

  // State management
  memoizedProps: any; // Props from last render
  pendingProps: any; // Props for this render
  memoizedState: any; // State from last render (hooks linked list for function components)
  updateQueue: UpdateQueue; // Queue of setState/useState updates

  // Effects
  flags: Flags; // Side effects for this Fiber (Placement, Update, Deletion)
  subtreeFlags: Flags; // Combined flags of all descendants
  deletions: Fiber[] | null; // Child Fibers to delete

  // Scheduling
  lanes: Lanes; // Priority of work on this Fiber
  childLanes: Lanes; // Priority of work in subtree

  // Double buffering
  alternate: Fiber | null; // Link to the other version of this Fiber

  // Host-specific
  stateNode: any; // DOM node for host components, class instance for classes
};

Key insights:

  • Linked list, not array: child, sibling, return pointers let React traverse without recursion
  • Double buffering: alternate links current Fiber to work-in-progress version
  • Flags for effects: Bitmasks indicate what has to be done (insert, update, delete)
  • Priority Lanes: Multiple concurrent updates can have different priorities

Why Linked Lists Enable Interruptibility

Arrays require contiguous traversal. Linked lists enable you to:

  • Pause at any node (save reference)
  • Resume from that node later
  • Skip subtrees efficiently
// Pseudocode for traversing Fiber tree
function workLoop(nextUnitOfWork) {
  while (nextUnitOfWork && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (nextUnitOfWork) {
    // More work remains, but we're out of time
    // Browser can paint, handle input, etc.
    // We'll resume in next frame
  } else {
    // All work complete, commit to DOM
    commitRoot();
  }
}

shouldYield() checks if we’ve exceeded the frame budget. This is how React “cooperates” with the browser’s rendering pipeline.

The Fiber Tree: Current vs Work-in-Progress

React maintains two Fiber trees:

  1. Current tree: What’s on screen now
  2. Work-in-progress (WIP) tree: The version being built

[fiber tree vs wrok in progress tree]

When you trigger a state update:

  1. React creates a WIP tree by cloning the existing tree.
  2. Reconciliation happens on the WIP tree (off-screen)
  3. Once complete, React swaps pointers: WIP becomes current
  4. Old current tree becomes the new WIP tree (recycled for next update)

This is a classic computer graphics method called double buffering. Drawing to an off-screen buffer is followed by an atomic buffer swap. Half-finished frames are never seen by the end user.

Fiber Traversal: BeginWork and CompleteWork

The Fiber tree is processed by React in two stages:

1. BeginWork (top-down):

  • Reconcile props/state
  • Determine if this Fiber can be skipped (bailout)
  • Create/update child Fibers
  • Set effect flags (Placement, Update, etc.)
  • Return first child as next unit of work

2. CompleteWork (bottom-up):

  • For host components, create/update DOM nodes (detached from document)
  • Bubble effect flags to parent
  • Build effect list (chain of Fibers with side effects)
  • Return sibling (if exists) or parent as next unit of work

The traversal pattern is as follows:

App (BeginWork)
 ├─ BeginWork → div
 │   ├─ BeginWork → h1
 │   │   ├─ BeginWork → "Hello" (text)
 │   │   └─ CompleteWork → "Hello"
 │   └─ CompleteWork → h1
 │   ├─ BeginWork → button
 │   │   ├─ BeginWork → "Click" (text)
 │   │   └─ CompleteWork → "Click"
 │   └─ CompleteWork → button
 └─ CompleteWork → div
CompleteWork → App

React uses depth-first traversal. When a node has no children, CompleteWork runs. If there’s a sibling, BeginWork on that sibling. If there are siblings, CompleteWork on the parent and continue up.

The Work Loop: Putting It All Together

This is a simplified version of the actual React source:

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate; // Current tree version

  // BeginWork phase
  let next = beginWork(current, unitOfWork, renderLanes);

  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // No child, move to CompleteWork
    completeUnitOfWork(unitOfWork);
  } else {
    // Child exists, make it next unit of work
    workInProgress = next;
  }
}

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;

  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    // CompleteWork phase
    completeWork(current, completedWork, renderLanes);

    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      // Continue with sibling
      workInProgress = siblingFiber;
      return;
    }

    // No sibling, return to parent
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}

Key points:

  • workInProgress is a global variable pointing to current Fiber being processed
  • performUnitOfWork processes one Fiber, returns next
  • Loop continues until workInProgress === null (tree complete)

BeginWork: Reconciliation Logic

BeginWork is where diffing happens:

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  // Check if we can bail out (skip work)
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps === newProps && !hasContextChanged()) {
      // Props unchanged, no context change → skip this subtree
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }

  // Clear lanes (we're doing the work now)
  workInProgress.lanes = NoLanes;

  // Switch on component type
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress, renderLanes);

    case ClassComponent:
      return updateClassComponent(current, workInProgress, renderLanes);

    case HostComponent:
      // <div>, <span>, etc.
      return updateHostComponent(current, workInProgress, renderLanes);

    // ... many more cases
    default:
      return null;
  }
}

For function components:

function updateFunctionComponent(current, workInProgress, renderLanes) {
  const Component = workInProgress.type;
  const props = workInProgress.pendingProps;

  // Call the function component
  let children = Component(props);

  // Reconcile children
  reconcileChildren(current, workInProgress, children, renderLanes);

  return workInProgress.child;
}

This is where your component function actually gets called!

CompleteWork: Building DOM Nodes

CompleteWork creates actual DOM nodes (but doesn’t attach them yet):

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case HostComponent: { // <div>, <span>, etc.
      const type = workInProgress.type; // "div", "span"

      if (current !== null && workInProgress.stateNode != null) {
        // Update existing DOM node
        updateHostComponent(current, workInProgress, type, newProps);
      } else {
        // Create new DOM node
        const instance = createInstance(type, newProps);

        // Append all children (already created by their CompleteWork)
        appendAllChildren(instance, workInProgress);

        // Store DOM node on Fiber
        workInProgress.stateNode = instance;
      }

      return null;
    }
    // ... other cases
  }
}

function createInstance(type, props) {
  const domElement = document.createElement(type);
  // Set props (event listeners, attributes)
  setInitialProperties(domElement, props);
  return domElement;
}

Important: These DOM nodes are detached; they are not yet in the actual document. This keeps work off-screen until commit.

The Commit Phase: Making Changes Visible

After the work loop completes, React has:

  • A complete WIP Fiber tree
  • DOM nodes created but detached
  • An effect list (chain of Fibers with side effects)

Now comes the commit phase, which is synchronous and uninterruptible:

function commitRoot(root) {
  const finishedWork = root.current.alternate; // WIP tree

  // Phase 1: Before mutation (read DOM, getSnapshotBeforeUpdate)
  commitBeforeMutationEffects(finishedWork);

  // Phase 2: Mutation (actual DOM changes)
  commitMutationEffects(finishedWork, root);

  // ATOMIC SWAP: WIP tree becomes current tree
  root.current = finishedWork;

  // Phase 3: Layout (useLayoutEffect, componentDidMount, refs)
  commitLayoutEffects(finishedWork, root);

  // Phase 4: Passive effects (useEffect) - scheduled as separate task
  scheduleCallback(ImmediatePriority, () => {
    flushPassiveEffects();
  });
}

Why are there three phases?

  1. Before Mutation: Read current DOM state (scroll positions, etc.) prior to making changes
  2. Mutation: Apply DOM changes (insertions, updates, deletions)
  3. Layout: Run effects that need to read new layout (useLayoutEffect measures DOM)

Passive effects (useEffect) run asynchronously after paint, so that they don’t delay the browser.

Effect Lists: Optimizing Commit

React doesn’t traverse the entire tree during commit. Instead, CompleteWork creates a linked list of only Fibers having side effects called a effect list.

Effect List: App → div → h1 (Update) → button (Placement)

                  (skip unchanged nodes)

This is why commit is fast even for large trees. If a 1000 components rendered but only 5 changed, React visited only those 5 during commit.

Practical Example: Tracing a Fiber Update

Let’s trace what happens when you click a counter button:

Initial State:

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(1)}>Increment</button>
    </div>
  );
}

Fiber Tree (simplified):

FiberRoot

Counter (FunctionComponent)

div (HostComponent, stateNode: <div> DOM element)
  ↓ child
h1 (HostComponent)
  ↓ child               ↓ sibling
"Count: 0" (HostText)  button (HostComponent)
                         ↓ child
                       "Increment" (HostText)

[Simplified Fiber Tre Diagram]

User clicks button:

  1. onClick fires, calls setCount(1)
  2. React schedules update on Counter Fiber:
const update = {
  lane: SyncLane, // High priority (user input)
  action: 1, // New state value
};
enqueueUpdate(counterFiber, update);
scheduleUpdateOnFiber(counterFiber, SyncLane);
  1. Work loop begins:

    • BeginWork on Counter:
      • Call Counter() function
      • useState processes queued update, returns [1, setCount]
      • Returns new element tree with count: 1
    • Reconcile children:
      • div: Same type, same props → skip, reuse
      • BeginWork on div:
        • h1: Same type → reconcile children
          • BeginWork on h1:
            • Text: “Count: 0” → “Count: 1” → mark Update flag
            • CompleteWork on text node
          • CompleteWork on h1
        • button: Same type, same props → skip
      • CompleteWork on div
    • CompleteWork on Counter
  2. Commit phase:

    • Traverse effect list (only h1 text node)
    • Mutation: textNode.nodeValue = "Count: 1"
    • Layout effects: none
    • Swap pointers: WIP tree → current tree
  3. Browser paints updated DOM

Total work: Updated one text node. Everything else reused.

Important Lessons: Fiber Architecture

  • Fiber nodes are work units having a tree structure (child, sibling, and return).
  • Interruptibility is made possible using linked lists (save progress, resume later).
  • Work is kept off-screen by double buffering (current + WIP trees).
  • BeginWork reconciles, CompleteWork builds, Commit applies
  • Optimize commit using effect lists (only visit modified nodes).
  • Different side effects are handled by the three commit phases (before mutation, mutation, and layout).

Fiber evolved React from a synchronous, blocking renderer into an asynchronous, priority-driven system. However, what is the true mechanism of prioritization?

Part 5 - Priorities and Scheduling: Making Wise Updates

The Priority Problem

Not all updates are created equal. Consider:

  • User typing in an input: Needs immediate feedback (< 100ms)
  • Data table sorting: Can wait a few frames
  • Background analytics: Doesn’t need to happen if user navigates away

The Stack Reconciler treated all updates the same; first come, first served. Fiber enables priority-based scheduling.

React’s Priority Levels

React defines several priority levels (React 17 introduced “Lanes”, but conceptually similar to older system):

PriorityTimeoutUse Case
ImmediatePriority (Sync)0msUser input, clicks, focus
UserBlockingPriority250msTyping, scrolling, drag
NormalPriority5sData fetching, network responses
LowPriority10sAnalytics, deferred logging
IdlePriorityNever expiresBackground tasks

When you call setState, React assigns a priority based on context:

  • Inside React event handler: SyncLane (immediate)
  • In startTransition: TransitionLane (low priority)
  • In setTimeout (React 18+): Default lane (normal priority)

Lanes: The Modern Priority Model

React 18 replaced expiration times with Lanes, a bitmask system representing concurrent work:

type Lane = number; // Each bit represents a priority
type Lanes = number; // Bitmask of multiple lanes

const SyncLane: Lane = 0b0000000000000000000000000000001;
const InputContinuousLane: Lane = 0b0000000000000000000000000000100;
const DefaultLane: Lane = 0b0000000000000000000000000010000;
const TransitionLane1: Lane = 0b0000000000000000000000001000000;
// ... 31 lanes total

Why bitmasks?

  • Multiple updates can share a lane (batching)
  • Fast bitwise operations to check/combine priorities
  • Better modeling of concurrent work than expiration times

Example:

// Check if Fiber has work in a specific lane
if (fiber.lanes & SyncLane) {
  // Has sync work
}

// Combine multiple updates
fiber.lanes = fiber.lanes | DefaultLane | TransitionLane1;

// Remove lanes after processing
fiber.lanes = fiber.lanes & ~SyncLane;

The Scheduler Package

React’s scheduler is a separate package (could be used by other frameworks). It manages:

  • Task queue: Pending work at various priorities
  • Time slicing: Breaking work into chunks
  • Yielding: Giving control back to browser

Simplified scheduler:

let taskQueue = []; // Priority queue
let currentTask = null;
let isPerformingWork = false;

function scheduleCallback(priorityLevel, callback) {
  const expirationTime = getCurrentTime() + timeoutForPriority(priorityLevel);

  const newTask = {
    callback,
    priorityLevel,
    expirationTime,
    sortIndex: expirationTime,
  };

  push(taskQueue, newTask); // Insert into priority queue

  if (!isPerformingWork) {
    requestHostCallback(flushWork);
  }

  return newTask;
}

function flushWork(hasTimeRemaining, initialTime) {
  isPerformingWork = true;

  try {
    return workLoop(hasTimeRemaining, initialTime);
  } finally {
    currentTask = null;
    isPerformingWork = false;
  }
}

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  currentTask = peek(taskQueue);

  while (currentTask !== null) {
    if (currentTask.expirationTime > currentTime && shouldYield()) {
      // Out of time, yield to browser
      break;
    }

    const callback = currentTask.callback;
    if (typeof callback === "function") {
      const continuationCallback = callback(didUserCallbackTimeout);

      if (typeof continuationCallback === "function") {
        // More work, same task
        currentTask.callback = continuationCallback;
      } else {
        // Task complete, remove from queue
        pop(taskQueue);
      }
    }

    currentTask = peek(taskQueue);
  }

  // Return true if more work remains
  return currentTask !== null;
}

Important concepts:

  • requestHostCallback: Scheduler’s way of scheduling itself (uses MessageChannel, setTimeout, or requestIdleCallback depending on environment)
  • shouldYield(): Checks if 5ms has elapsed (Scheduler’s default time slice)
  • Continuation callbacks: Tasks can return a function to continue later

shouldYield: The Time-Slicing Mechanism

Periodically, the scheduler gives away control so that the browser can paint/handle input:

let frameYieldMs = 5; // Default time slice
let deadline = 0;

function shouldYield() {
  const currentTime = getCurrentTime();
  return currentTime >= deadline;
}

function requestHostCallback(callback) {
  // Set next deadline
  deadline = getCurrentTime() + frameYieldMs;

  // Schedule callback (MessageChannel for fast scheduling)
  port.postMessage(null);
}

React pauses the work loop and gives the browser control when shouldYield() returns true. React then resumes in the next loop tick.

Why 5 ms? To strike a balance between:

  • Longer slices: More efficient (less overhead from yielding)
  • Shorter slices: More responsive (browser can update more often)

5ms gives about 200 yields per second, smooth non-thrashing animations.

Priority Inversion and Starvation Prevention

Priority Inversion: High-priority updates are blocked by low-priority ones. Imagine:

  • A low-priority data table update begins (1000 ms of work)
  • The user presses a button (high priority)
  • Clicking the button requires waiting for the data table? ❌

Fiber’s solution is to interrupt low-priority work, process high-priority updates, and then resume low-priority at a later time (or discard if stale).

Starvation: Low-priority work never completes because high-priority updates keep preempting it, preventing it from ever being finished.

Fiber’s solution: Expiration times. Eventually, even low-priority work becomes urgent:

// Simplified expiration logic
function markUpdateLaneFromFiberToRoot(fiber, lane) {
  fiber.lanes = mergeLanes(fiber.lanes, lane);

  // If this update is about to expire, upgrade to sync lane
  if (isExpired(lane)) {
    fiber.lanes = mergeLanes(fiber.lanes, SyncLane);
  }

  // Bubble up to root
  let parent = fiber.return;
  while (parent !== null) {
    parent.childLanes = mergeLanes(parent.childLanes, fiber.lanes);
    parent = parent.return;
  }
}

A low-priority update get upgraded to sync after around 5 seconds (for NormalPriority), ensuring that it finally runs.

Automatic Batching in React 18

Pre-React 18, only updates inside React event handlers were batched:

// React 17: Two renders
setTimeout(() => {
  setCount(1); // Render #1
  setFlag(true); // Render #2
}, 1000);

React 18+ batches everywhere thanks to Fiber + Scheduler:

// React 18: One render
setTimeout(() => {
  setCount(1);
  setFlag(true); // Batched with above
}, 1000);

How? React 18 wraps all callbacks in a scheduling context:

// Simplified React 18 batching
function batchedUpdates(callback) {
  const prevExecutionContext = executionContext;
  executionContext |= BatchedContext;

  try {
    return callback();
  } finally {
    executionContext = prevExecutionContext;

    // Flush batched updates
    if (executionContext === NoContext) {
      flushSyncCallbacks();
    }
  }
}

This means less thrashing, better performance by default.

Escape hatch (flushSync): If you need immediate update:

import { flushSync } from "react-dom";

flushSync(() => {
  setCount(1); // Forces immediate render
});
// DOM is updated here
console.log(ref.current.textContent); // Reflects new count

Should be used sparingly because it defeats batching optimizations.

Practical Example: Priority in Action

import { useState, useTransition } from "react";

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    const value = e.target.value;
    setQuery(value); // High priority (user input)

    // Low priority (expensive filtering)
    startTransition(() => {
      const filtered = items.filter((item) =>
        item.toLowerCase().includes(value.toLowerCase()),
      );
      setFilteredItems(filtered);
    });
  };

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <Spinner />}
      <ul>
        {filteredItems.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </>
  );
}

What happens:

  1. User types “a” in input
  2. setQuery('a'): Sync priority → input updates immediately
  3. startTransition(() => ...): Marks filtering as low priority
  4. React reconciles input first (fast)
  5. Browser paints → user sees “a” in input immediately
  6. React starts filtering (can be interrupted)
  7. If user types “ab” while filtering:
    • React discards incomplete filtering work
    • Processes new input sync priority
    • Starts new filtering
  8. Once filtering completes uninterrupted, list updates

Result: Input stays responsive even with 100,000 items. Filtering doesn’t block typing.

Important Lessons: Scheduling and Priorities

  • Update urgency is determined by priorities (sync → idle)
  • Lanes are bitmasks representing concurrent work
  • The scheduler handles time-slicing (5 ms chunks, yields to browser).
  • Priority inversion is prevented by interrupting low-priority work
  • Starvation is prevented by expiration times
  • Automatic batching (React 18+) reduces unnecessary renders
  • startTransition marks low-priority work keeping the UI responsive

Let’s now look into concurrent features that are built on this foundation.

Part 6 - Concurrent React: startTransition, useDeferredValue, and Suspense

What Is Concurrent Rendering?

Concurrent Mode (now just “Concurrent Features” in React 18+) means React can work on multiple updates at once without committing any until ready.

Key difference from legacy mode:

Legacy ModeConcurrent Mode
One update at a timeMultiple updates in progress
Blocking (all-or-nothing)Interruptible (pausable/resumable)
No prioritizationPriority-based scheduling
FIFO queuePriority lanes

Opt-in (React 18+): Use createRoot instead of render:

// Legacy (blocking mode)
import ReactDOM from "react-dom";
ReactDOM.render(<App />, document.getElementById("root"));

// Concurrent (React 18+)
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root"));
root.render(<App />);

With createRoot, all concurrent features (transitions, Suspense, automatic batching) work.

startTransition: Marking Low-Priority Updates

Problem: Expensive state updates block UI. Example—filtering a large list:

const [query, setQuery] = useState("");
const [items, setItems] = useState(bigList);

const handleChange = (e) => {
  setQuery(e.target.value);

  // Expensive filtering
  const filtered = bigList.filter((item) => item.includes(e.target.value));
  setItems(filtered); // Blocks input until done
};

Solution: Wrap low-priority update in startTransition:

import { useState, useTransition } from "react";

const [query, setQuery] = useState("");
const [items, setItems] = useState(bigList);
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
  setQuery(e.target.value); // High priority (sync)

  startTransition(() => {
    // Low priority (interruptible)
    const filtered = bigList.filter((item) => item.includes(e.target.value));
    setItems(filtered);
  });
};

return (
  <>
    <input value={query} onChange={handleChange} />
    {isPending && <Spinner />}
    <ItemList items={items} />
  </>
);

What changed:

  1. Input updates immediately (high priority)
  2. Filtering happens in the background (low priority, can be interrupted)
  3. isPending lets you show loading state during transition

Under the hood:

function startTransition(callback) {
  const prevTransition = ReactCurrentBatchConfig.transition;
  ReactCurrentBatchConfig.transition = 1; // Mark as transition

  try {
    callback();
  } finally {
    ReactCurrentBatchConfig.transition = prevTransition;
  }
}

Any setState inside callback gets marked with TransitionLane (low priority).

useDeferredValue: Creating “Lagging” State

Alternative approach: Keep state in sync, but defer expensive rendering:

import { useState, useDeferredValue } from "react";

function SearchResults({ query }) {
  // This value "lags behind" query during transitions
  const deferredQuery = useDeferredValue(query);

  // Expensive component only re-renders when deferredQuery changes
  return <ExpensiveList query={deferredQuery} />;
}

function App() {
  const [query, setQuery] = useState("");

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <SearchResults query={query} />
    </>
  );
}

How it works:

  1. User types → query updates immediately
  2. deferredQuery doesn’t update right away
  3. React reconciles <input> first (fast)
  4. Browser paints
  5. React starts reconciling <ExpensiveList> with old deferredQuery
  6. When ready, deferredQuery updates to match query
  7. <ExpensiveList> re-renders with new value

Difference from startTransition:

  • startTransition: You control where low-priority work happens
  • useDeferredValue: React automatically defers (postpones) re-renders for expensive components

When to use which:

  • startTransition: You have control over the state update (event handlers)
  • useDeferredValue: State comes from props/context that you don’t control

Tearing: The Consistency Challenge

Problem: Concurrent rendering can cause “tearing” where different components see different versions of state.

Imagine:

  • React starts reconciling with count: 0
  • Midway through, count updates to 1
  • Some components rendered with count: 0, others with count: 1
  • UI is inconsistent

React’s solution: All components in a single render see the same snapshot of state. React “freezes” state for the duration of a render:

// Simplified state management
let currentState = 0;
let renderSnapshot = 0;

function scheduleRender() {
  // Freeze current state as snapshot
  renderSnapshot = currentState;

  // All components see renderSnapshot during this render
  startWorkLoop();
}

function useState() {
  // Return snapshot, not live state
  return [renderSnapshot, setState];
}

Even if state updates mid-render, components see consistent values.

Suspense: Async Rendering Boundaries

Suspense lets components “wait” for async data without blocking siblings:

import { Suspense } from "react";

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfilePage />
    </Suspense>
  );
}

function ProfilePage() {
  // "use" hook unwraps promises (React 19+)
  const user = use(fetchUser());

  return <div>Hello {user.name}</div>;
}

How it works:

  1. React starts rendering <ProfilePage>
  2. fetchUser() returns pending promise
  3. use() “throws” the promise (unconventional control flow)
  4. React catches promise and renders <Spinner> instead
  5. When promise resolves, React retries <ProfilePage>
  6. use() now returns data, the component renders normally

Throwing promises? Yes, React handles thrown promises specially:

// Simplified Suspense implementation
function ProfilePage() {
  const promise = fetchUser();

  if (promise.status === "pending") {
    throw promise; // React catches this
  }

  const user = promise.value;
  return <div>Hello {user.name}</div>;
}

React’s reconciler has special handling for thrown promises:

function handleThrow(thrownValue) {
  if (thrownValue instanceof Promise) {
    // It's a promise—suspend rendering
    attachPingListener(thrownValue, () => {
      // Retry when promise resolves
      scheduleUpdateOnFiber(workInProgress);
    });

    // Find nearest Suspense boundary
    const suspenseBoundary = findNearestSuspenseBoundary(workInProgress);
    suspenseBoundary.flags |= ShouldCapture;
  } else {
    // Regular error—find error boundary
    findNearestErrorBoundary(workInProgress);
  }
}

Suspense in SSR: Selective Hydration

Traditional SSR flow:

  1. Server renders entire app to HTML
  2. Send HTML to client
  3. Load all JavaScript
  4. Hydrate entire app (attach event listeners)
  5. App interactive

Problem: Slow components block everything.

React 18 Suspense SSR:

<Suspense fallback={<Spinner />}>
  <SlowComponent />
</Suspense>

The new flow:

  1. Everything is rendered by the server except for <SlowComponent> (which displays <Spinner>).
  2. Immediately send HTML to the client
  3. The client quickly sees a portion of the UI (better FCP/LCP)
  4. When ready, the server streams <SlowComponent> HTML
  5. The client hydrates interactive elements first (selective hydration).
  6. Lastly, <SlowComponent> hydrates

Benefits:

  • Faster initial paint
  • Progressive interactivity
  • User can interact with the parts that are ready while others load

Zero-Bundle Server Components (React 19+)

React Server Components (RSC) run only on the server, never sent to the client:

// ServerComponent.server.js
async function ServerComponent() {
  const data = await db.query("SELECT * FROM posts");

  return (
    <div>
      {data.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Key differences:

Client ComponentsServer Components
Run on client (browser)Run on server only
Included in JS bundleZero bundle cost
Can use hooks, stateNo hooks, no state
Can import server componentsCannot import client components

Why useful:

  • Large dependencies (markdown parsers, syntax highlighters) stay on the server for bundle size. 
  • Direct database access eliminates the need for an API layer. 
  • API keys and secrets stay on the server for security.

Serialization protocol: Server components return special JSON, not HTML:

{
  "type": "div",
  "props": {
    "children": [
      { "type": "PostCard", "props": { "post": {...} } }
    ]
  }
}

Client reconstructs React tree from this JSON, mixing with client components.

Key Takeaways: Concurrent React

  1. Concurrent rendering enables interruptibility and prioritization
  2. startTransition marks low-priority updates keeping UI responsive
  3. useDeferredValue defers expensive renders automatically
  4. Tearing prevented by snapshotting state
  5. Suspense enables async boundaries with progressive rendering
  6. Selective hydration (SSR) makes parts interactive before others
  7. Server Components reduce bundle size for data-heavy components

These features are built on Fiber’s foundation. Now let’s explore React 19.2’s latest additions.

Part 7 - Innovations in React 19.2: Activity, useEffectEvent, and Performance Tracks

React 19.2 offers several powerful new features that take advantage of Fiber’s architecture. Let’s analyze each one.

The <Activity> Component: State-Preserving Visibility Control

Problem: Imagine a tabbed interface with complex state for the tabs (forms, scroll positions). With traditional conditional rendering:

// Traditional approach
{
  activeTab === "settings" && <SettingsPanel />;
}
{
  activeTab === "profile" && <ProfilePanel />;
}

Switching tabs unmounts the inactive panel, losing:

  • Form input values
  • Scroll positions
  • Fetched data
  • Component state

Solutions before Activity:

  1. Lift state up (complex, doesn’t solve all cases)
  2. Use display: none (keeps side effects running, wastes resources)
  3. Manually cache state in parent (tedious, error-prone)

React 19.2 Solution: <Activity> component

import { Activity } from "react";

function TabbedInterface() {
  const [activeTab, setActiveTab] = useState("settings");

  return (
    <>
      <Activity mode={activeTab === "settings" ? "visible" : "hidden"}>
        <SettingsPanel />
      </Activity>

      <Activity mode={activeTab === "profile" ? "visible" : "hidden"}>
        <ProfilePanel />
      </Activity>
    </>
  );
}

Activity modes:

  • visible: Component fully active (renders, effects run, updates process normally)
  • hidden: Component hidden but state preserved:
    • React keeps component tree mounted
    • All effects are cleaned up (no background work)
    • State updates are deferred to idle priority (won’t run until browser has spare time)
    • DOM nodes hidden by CSS (like display: none)

Main benefits:

  • Instant navigation: Return back to hidden tab → no loading and state is intact
  • Resource efficient: Hidden components don’t run effects (no timers, subscriptions)
  • Smooth user experience: No remounting flicker, preserved scroll positions

Under the hood (simplified):

function updateActivity(current, workInProgress) {
  const { mode } = workInProgress.pendingProps;

  if (mode === "hidden") {
    // Mark all effects for cleanup
    workInProgress.flags |= PassiveUnmountPendingDev;

    // Defer updates to idle priority
    workInProgress.lanes = IdleLane;

    // Hide DOM nodes
    if (workInProgress.stateNode) {
      workInProgress.stateNode.style.display = "none";
    }
  } else {
    // Restore effects
    workInProgress.flags |= PassiveMountPendingDev;

    // Normal priority
    workInProgress.lanes = DefaultLane;

    // Show DOM nodes
    if (workInProgress.stateNode) {
      workInProgress.stateNode.style.display = "";
    }
  }

  reconcileChildren(
    current,
    workInProgress,
    workInProgress.pendingProps.children,
  );
  return workInProgress.child;
}

Practical use cases:

  1. Multi-step forms: Keep previous steps mounted and preserve inputs
  2. Dashboard tabs: Load data once, switch tabs instantly
  3. Modal dialogs: Pre-render expensive modals in hidden state, display instantly
  4. Video/audio players: Pause media when hidden and resume when visible

Example: Pre-rendering for instant navigation

function App() {
  const [currentPage, setCurrentPage] = useState("home");
  const [nextPage, setNextPage] = useState(null);

  // Predictive preloading
  useEffect(() => {
    if (currentPage === "home") {
      setNextPage("products"); // Pre-render likely next page
    }
  }, [currentPage]);

  return (
    <>
      <Activity mode={currentPage === "home" ? "visible" : "hidden"}>
        <HomePage />
      </Activity>

      <Activity mode={currentPage === "products" ? "visible" : "hidden"}>
        <ProductsPage />
      </Activity>

      {/* Pre-rendered but hidden */}
      {nextPage && currentPage !== nextPage && (
        <Activity mode="hidden">
          {nextPage === "products" && <ProductsPage />}
        </Activity>
      )}
    </>
  );
}

When user clicks “Products” the page appears instantly (already rendered in background).

Memory considerations: Hidden components still consume memory. For many tabs, implement LRU eviction:

const MAX_CACHED_TABS = 3;

function TabbedInterface() {
  const [activeTab, setActiveTab] = useState("home");
  const [cachedTabs, setCachedTabs] = useState(["home"]);

  const switchTab = (tab) => {
    setActiveTab(tab);

    setCachedTabs((prev) => {
      const updated = [tab, ...prev.filter((t) => t !== tab)];
      return updated.slice(0, MAX_CACHED_TABS); // Keep only 3 most recent
    });
  };

  return (
    <>
      {ALL_TABS.map((tab) => (
        <Activity
          key={tab}
          mode={
            tab === activeTab
              ? "visible"
              : cachedTabs.includes(tab)
                ? "hidden"
                : null // Unmount if not in cache
          }
        >
          {cachedTabs.includes(tab) || tab === activeTab ? (
            <TabContent tab={tab} />
          ) : null}
        </Activity>
      ))}
    </>
  );
}

useEffectEvent: Separating Reactive and Non-Reactive Logic

Problem: Effects with “event-like” callbacks that need latest props/state:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);

    connection.on("connected", () => {
      showNotification("Connected!", theme); // Uses latest theme
    });

    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]); // theme in deps causes reconnect on theme change ❌
}

Problem: Changing theme shouldn’t reconnect WebSocket, but effect deps require it.

Pre-19.2 workarounds:

  1. Omit from deps (lint error, potential stale closure)
  2. Use ref (verbose, manual):
const themeRef = useRef(theme);
useEffect(() => {
  themeRef.current = theme;
});
  1. Custom hook (community solutions like useEvent):
function useEvent(handler) {
  const handlerRef = useRef();
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });
  return useCallback((...args) => handlerRef.current(...args), []);
}

React 19.2 Solution: useEffectEvent

import { useEffect, useEffectEvent } from "react";

function ChatRoom({ roomId, theme }) {
  // Effect Event: stable identity, always fresh closure
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme); // Latest theme
  });

  useEffect(() => {
    const connection = createConnection(roomId);

    connection.on("connected", onConnected); // Stable reference

    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // Only roomId in deps ✓
}

How it works:

// Simplified implementation
function useEffectEvent(handler) {
  const handlerRef = useRef();

  // Update ref in layout effect (before user effects)
  useLayoutEffect(() => {
    handlerRef.current = handler;
  });

  // Return stable callback
  return useCallback((...args) => {
    const fn = handlerRef.current;
    return fn(...args);
  }, []); // Empty deps → stable identity
}

Key characteristics:

  • Consistent identity: The onConnected reference never changes
  • Fresh closures: Always displays the most recent props/state
  • Not reactive: Changing theme doesn’t re-run effect
  • Linter-aware: ESLint plugin v6 knows not to require in deps

Use cases:

  • Event subscriptions: WebSocket, EventSource, window events
  • Analytics: Log events with current user context
  • Timers: setTimeout/setInterval callbacks needing latest state

Anti-pattern (not to abuse):

// Bad: Using useEffectEvent to silence lint warnings
const onSomething = useEffectEvent(() => {
  actuallyReactiveLogic(dependency); // Should be in deps!
});

useEffect(() => {
  onSomething();
}, []); // ❌ Missing dependency that should trigger effect

Good: Only use for truly “event-like” logic that shouldn’t be reactive.

Restrictions:

  • Must be declared in the same component as effect (can’t pass to children)
  • Only call from inside effects (not in render or other event handlers)

ESLint Integration (plugin v6):

// .eslintrc
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "error" // Now understands useEffectEvent
  }
}

Linter won’t warn about onConnected missing from deps.

Results:

  • Input lag: 500ms → 10ms
  • Filter application: Instant (deferred to background)
  • Memory usage: Reduced (only 20 table rows rendered)
  • Chart re-renders: Eliminated (memoization)

Performance Tracks confirmed:

  • Input events: UserBlocking priority, <5ms
  • Filtering: Transition priority, yields to input
  • No dropped frames

Part 8 - Applying Knowledge to Real Projects

Understanding internals is only valuable if it improves your actual work. Here’s how I applied this knowledge to my indie projects.

Architecture Decisions: When to Use React

Not every project needs React. Understanding its complexity helped me choose appropriately:

Use React when:

  • Complex state management (dashboards, admin panels)
  • Frequent updates (real-time data, collaborative tools)
  • Component reuse across large app
  • Need ecosystem (libraries built for React)

Consider alternatives when:

  • Static content (use Astro, Next.js SSG, or plain HTML)
  • Simple interactivity (use Alpine.js, htmx, or vanilla JS)
  • Performance-critical (use Preact, Solid, or Svelte for smaller bundles)

My rule: Start simple, add complexity only when needed.

Island Architecture: Selective Hydration

For content-heavy sites (blogs, marketing pages), I use island architecture:

---
// Astro component (runs on server only)
import InteractiveChart from './InteractiveChart.jsx';
---

<html>
  <body>
    <!-- Static HTML (no JS) -->
    <header>
      <h1>My Blog</h1>
    </header>

    <article>
      <!-- Static content -->
      <p>Lorem ipsum...</p>

      <!-- Interactive "island" (hydrated with React) -->
      <InteractiveChart client:visible data={chartData} />

      <!-- More static content -->
      <p>More text...</p>
    </article>
  </body>
</html>

Result:

  • Most page is static HTML (instant load)
  • Only <InteractiveChart> hydrates (lazy-loaded)
  • Bundle size: 5KB instead of 150KB

Understanding React’s hydration process made this pattern clear. I know the cost of shipping React, so I minimize it.

State Management: Choosing the Right Tool

Context vs Zustand vs Redux:

Understanding React’s reconciliation helped me choose:

Context (built-in):

  • Good for infrequent updates (theme, auth, i18n)
  • Bad for frequent updates (causes re-renders down tree)
// Every component consuming ThemeContext re-renders when theme changes
const theme = useContext(ThemeContext);

Zustand (lightweight external store):

  • Good for frequent updates (only subscribers re-render)
  • Simple API, small bundle
  • No Provider hell
// Only this component re-renders when count changes
const count = useStore((state) => state.count);

Redux (complex external store):

  • Good for large teams (enforced patterns, devtools)
  • Overkill for small projects

My choice for indie projects: Zustand for global state, Context for config, local useState for component state.

SSR/SSG Trade-offs

Understanding Fiber and Server Components clarified when to use each:

Static Site Generation (SSG):

  • Pre-render at build time
  • Fastest (serve from CDN)
  • Best for content that doesn’t change often

Server-Side Rendering (SSR):

  • Render on each request
  • Personalized content
  • Slower TTFB than SSG

Server Components (React 19+):

  • Zero bundle cost for data-heavy components
  • Server-side execution
  • Great for dashboards with lots of data fetching

My setup (Astro + React):

  • Marketing pages: SSG (build once, serve millions)
  • Dashboard: SSR with selective hydration (fast FCP, interactive islands)
  • Data-heavy admin: Server Components (zero bundle cost for tables)

Monitoring: Real User Monitoring

Profiler API in production:

import { Profiler } from "react";

function onRenderCallback(id, phase, actualDuration) {
  // Only log slow renders
  if (actualDuration > 16) {
    logToAnalytics({
      component: id,
      phase,
      duration: actualDuration,
      url: window.location.pathname,
      userAgent: navigator.userAgent,
    });
  }
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <Router>
        <Routes />
      </Router>
    </Profiler>
  );
}

This caught regressions:

  • “Users on slow devices experiencing lag” → profiler showed Chart taking 200ms
  • Fixed with useDeferredValue
  • Confirmed improvement with profiler showing <50ms

Performance Budgets

Understanding Fiber’s 16ms frame budget informed my performance goals:

Budgets for 60fps:

  • Each component render: <16ms
  • JavaScript execution per frame: <10ms (leave 6ms for browser)
  • Total Blocking Time (TBT): <200ms
  • First Input Delay (FID): <100ms

Tools:

  • Lighthouse CI (fail build if budgets exceeded)
  • Chrome Performance Tracks (verify Fiber scheduling working as expected)
  • Bundle size limits (enforce code splitting)

React 19.2 Opportunities

Activity for dashboard tabs:

function Dashboard() {
  const [activeTab, setActiveTab] = useState("overview");

  return (
    <>
      <Tabs value={activeTab} onChange={setActiveTab} />

      <Activity mode={activeTab === "overview" ? "visible" : "hidden"}>
        <OverviewTab /> {/* Expensive charts */}
      </Activity>

      <Activity mode={activeTab === "analytics" ? "visible" : "hidden"}>
        <AnalyticsTab /> {/* Expensive tables */}
      </Activity>
    </>
  );
}

Result: Instant tab switching, no data refetching, preserved filter states.

useEffectEvent for analytics:

function ProductPage({ productId, user }) {
  const trackView = useEffectEvent(() => {
    analytics.track("product_viewed", {
      productId,
      userId: user.id, // Latest user (if they log in mid-session)
      timestamp: Date.now(),
    });
  });

  useEffect(() => {
    trackView();
  }, [productId]); // Only track when productId changes, not user
}

Performance Tracks for profiling:

During development, I record profiles regularly:

  • Check Scheduler track: Are transitions working? Any priority inversions?
  • Check Components track: Which components are slow?
  • Fix issues before they reach production

Final Thoughts:

When I first started this journey, React felt like magic. JSX somehow became DOM, state updates caused re-renders, and hooks worked through mysterious closures.

Now, after dissecting JSX transpilation, tracing Fiber reconciliation, and applying this knowledge to real projects, React feels like understandable engineering.

Each concept opened the door to the next. JSX led to elements, elements to reconciliation, reconciliation to Fiber, and Fiber to concurrent features. Patterns finally emerged from disorder.

If you’ve made it this far, you’re clearly curious about how things work under the hood. Here’s my challenge:

  1. Clone the React source code and trace a basic state update by setting breakpoints in BeginWork. 

  2. Create your own mini-React: Instead of just reading the code, type it out, break it, and fix it.

  3. Profile a real application: Identify and address a bottleneck using DevTools Profiler and Performance Tracks.

  4. Make a contribution: Get used to the ecosystem. Even a typo correction in documents counts.

  5. Share what you’ve learned: Write a blog article or a social media post.

The React ecosystem is large, friendly, and created by individuals just like us who were interested and made the decision to learn more.


Resources

Official Documentation:

Tools: