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
![[react fiber architecture]](/images/gallery/react_fiber_internals_1.jpg)
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(
- Compare the new element tree with the previous one
- Identify the changes (diffing)
- 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:
- Lost UI state: Form values, input focus, and scroll positions are all gone.
- Expensive: Creating DOM nodes is slow, and it is wasteful to delete and recreate them.
- 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:
- 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. - 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):
- React creates Fiber node for
Counter - Calls
Counter()function → returns element tree - Creates Fiber nodes:
div→h1→ text,button→ text - Creates DOM nodes:
<div>,<h1>, text node “Count: 0”,<button>, text node ”+” - Inserts into container
After clicking button (update):
setCount(1)schedules update- React calls
Counter()again → new element tree - Reconciliation begins at root
div:- Type:
div→div✓ (same, update props) - Props:
{}→{}✓ (unchanged, skip) - Children: Recurse
- Type:
- Reconcile
h1:- Type:
h1→h1✓ - Props:
{}→{}✓ - Children: text “Count: 0” → “Count: 1” ✗ (changed!)
- Mark for update
- Type:
- Reconcile
button:- Type:
button→button✓ - Props:
{ onClick: ... }→{ onClick: ... }✓ (same reference) - Children: text ”+” → ”+” ✓
- Skip update
- Type:
- 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]](/images/gallery/call_stack_blocked.png)
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]](/images/gallery/blocking_reconciliation_diagram.png)
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:
- Pausable rendering: React could reconcile part of the tree, yield to browser, continue later
- Priority-based updates: User input (click, type) should interrupt low-priority work (data table update)
- 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
| Aspect | Stack Reconciler | Fiber Reconciler |
|---|---|---|
| Algorithm | Synchronous recursion | Asynchronous, pausable work loop |
| Interruptibility | None—all or nothing | Can pause at any Fiber node |
| Prioritization | None—FIFO queue | Priority lanes (urgent → idle) |
| Data Structure | Call stack (implicit) | Linked list of Fiber nodes |
| Concurrency | No | Yes (React 18+) |
| Work Reuse | Limited | Can 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,returnpointers let React traverse without recursion - Double buffering:
alternatelinks 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:
- Current tree: What’s on screen now
- Work-in-progress (WIP) tree: The version being built
![[fiber tree vs wrok in progress tree]](/images/gallery/fiber_tree_wip.png)
When you trigger a state update:
- React creates a WIP tree by cloning the existing tree.
- Reconciliation happens on the WIP tree (off-screen)
- Once complete, React swaps pointers: WIP becomes current
- 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:
workInProgressis a global variable pointing to current Fiber being processedperformUnitOfWorkprocesses 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?
- Before Mutation: Read current DOM state (scroll positions, etc.) prior to making changes
- Mutation: Apply DOM changes (insertions, updates, deletions)
- 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]](/images/gallery/simplified_fiber_tree_diagram.png)
User clicks button:
- onClick fires, calls
setCount(1) - React schedules update on
CounterFiber:
const update = {
lane: SyncLane, // High priority (user input)
action: 1, // New state value
};
enqueueUpdate(counterFiber, update);
scheduleUpdateOnFiber(counterFiber, SyncLane);
-
Work loop begins:
- BeginWork on
Counter:- Call
Counter()function useStateprocesses queued update, returns[1, setCount]- Returns new element tree with
count: 1
- Call
- 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
- BeginWork on
button: Same type, same props → skip
- CompleteWork on
div
- CompleteWork on
Counter
- BeginWork on
-
Commit phase:
- Traverse effect list (only
h1text node) - Mutation:
textNode.nodeValue = "Count: 1" - Layout effects: none
- Swap pointers: WIP tree → current tree
- Traverse effect list (only
-
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):
| Priority | Timeout | Use Case | |
|---|---|---|---|
| ImmediatePriority (Sync) | 0ms | User input, clicks, focus | |
| UserBlockingPriority | 250ms | Typing, scrolling, drag | |
| NormalPriority | 5s | Data fetching, network responses | |
| LowPriority | 10s | Analytics, deferred logging | |
| IdlePriority | Never expires | Background 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:
- User types “a” in input
setQuery('a'): Sync priority → input updates immediatelystartTransition(() => ...): Marks filtering as low priority- React reconciles input first (fast)
- Browser paints → user sees “a” in input immediately
- React starts filtering (can be interrupted)
- If user types “ab” while filtering:
- React discards incomplete filtering work
- Processes new input sync priority
- Starts new filtering
- 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 Mode | Concurrent Mode |
|---|---|
| One update at a time | Multiple updates in progress |
| Blocking (all-or-nothing) | Interruptible (pausable/resumable) |
| No prioritization | Priority-based scheduling |
| FIFO queue | Priority 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:
- Input updates immediately (high priority)
- Filtering happens in the background (low priority, can be interrupted)
isPendinglets 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:
- User types →
queryupdates immediately deferredQuerydoesn’t update right away- React reconciles
<input>first (fast) - Browser paints
- React starts reconciling
<ExpensiveList>with olddeferredQuery - When ready,
deferredQueryupdates to matchquery <ExpensiveList>re-renders with new value
Difference from startTransition:
startTransition: You control where low-priority work happensuseDeferredValue: 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,
countupdates to1 - Some components rendered with
count: 0, others withcount: 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:
- React starts rendering
<ProfilePage> fetchUser()returns pending promiseuse()“throws” the promise (unconventional control flow)- React catches promise and renders
<Spinner>instead - When promise resolves, React retries
<ProfilePage> 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:
- Server renders entire app to HTML
- Send HTML to client
- Load all JavaScript
- Hydrate entire app (attach event listeners)
- App interactive
Problem: Slow components block everything.
React 18 Suspense SSR:
<Suspense fallback={<Spinner />}>
<SlowComponent />
</Suspense>
The new flow:
- Everything is rendered by the server except for
<SlowComponent>(which displays<Spinner>). - Immediately send HTML to the client
- The client quickly sees a portion of the UI (better FCP/LCP)
- When ready, the server streams
<SlowComponent>HTML - The client hydrates interactive elements first (selective hydration).
- 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 Components | Server Components |
|---|---|
| Run on client (browser) | Run on server only |
| Included in JS bundle | Zero bundle cost |
| Can use hooks, state | No hooks, no state |
| Can import server components | Cannot 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
- Concurrent rendering enables interruptibility and prioritization
- startTransition marks low-priority updates keeping UI responsive
- useDeferredValue defers expensive renders automatically
- Tearing prevented by snapshotting state
- Suspense enables async boundaries with progressive rendering
- Selective hydration (SSR) makes parts interactive before others
- 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:
- Lift state up (complex, doesn’t solve all cases)
- Use
display: none(keeps side effects running, wastes resources) - 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:
- Multi-step forms: Keep previous steps mounted and preserve inputs
- Dashboard tabs: Load data once, switch tabs instantly
- Modal dialogs: Pre-render expensive modals in hidden state, display instantly
- 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:
- Omit from deps (lint error, potential stale closure)
- Use ref (verbose, manual):
const themeRef = useRef(theme);
useEffect(() => {
themeRef.current = theme;
});
- 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
onConnectedreference never changes - Fresh closures: Always displays the most recent props/state
- Not reactive: Changing
themedoesn’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:
-
Clone the React source code and trace a basic state update by setting breakpoints in BeginWork.
-
Create your own mini-React: Instead of just reading the code, type it out, break it, and fix it.
-
Profile a real application: Identify and address a bottleneck using DevTools Profiler and Performance Tracks.
-
Make a contribution: Get used to the ecosystem. Even a typo correction in documents counts.
-
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:
- React Docs - Start here for API reference
- React Source Code - Read BeginWork, CompleteWork, scheduler
Tools:
- React DevTools - Profiler and component inspector
- AST Explorer - Visualize JSX compilation
- React Compiler Playground - Experiment with React Compiler