
I've been writing React professionally for about a year and a half. I can build features, fix bugs, and ship code that works. I know how to use hooks, optimize re-renders, and debug performance issues. But I had an uncomfortable realization: I didn't truly understand how React works.
I could use useState and watch the DOM update. I could read the docs and follow best practices. But I couldn't answer basic questions like: Why doesn't the entire page flash when one state changes? What's actually happening between setState and the DOM updating? How does React know what to re-render?
So I stopped reading articles and did something uncomfortable: I built React from scratch. Then I rebuilt it. Then I read the actual React source code.
What I discovered changed how I think about frontend engineering entirely.
The Uncomfortable Truth About Most React Developers
We're all using a sophisticated scheduler and we don't even know it.
Most React developers think in terms of:
- Components
- Props and state
- Re-renders
But that's just the API. Under the hood, React is solving a fundamentally hard problem:
How do you update a complex UI without blocking the browser's main thread?
The answer is Fiber. And Fiber is not what you think it is.
Starting Simple: My First React Clone
I started with the basics. Virtual DOM, diffing, rendering. Standard stuff.
function createElement(type, props, ...children) {
return { type, props: props || {}, children };
}
function render(vnode, container) {
if (typeof vnode === "string") {
container.appendChild(document.createTextNode(vnode));
return;
}
const dom = document.createElement(vnode.type);
// Handle props
Object.keys(vnode.props || {}).forEach(key => {
if (key.startsWith("on")) {
const event = key.substring(2).toLowerCase();
dom.addEventListener(event, vnode.props[key]);
} else if (key === "className") {
dom.className = vnode.props[key];
} else if (key !== "children") {
dom[key] = vnode.props[key];
}
});
vnode.children.forEach(child => render(child, dom));
container.appendChild(dom);
}
This works. First render is fine. But here's where it gets interesting.
The Diffing Algorithm: Where Performance Lives
When state changes, you can't just blow away the entire DOM. That's slow and loses focus, scroll position, and input state.
So you diff. Compare old Virtual DOM with new Virtual DOM. Update only what changed.
function diff(oldVNode, newVNode, dom, parent, index = 0) {
// Node removed
if (!newVNode) {
parent.removeChild(dom);
return null;
}
// Node added
if (!oldVNode) {
const newDom = render(newVNode, document.createElement('div'));
parent.appendChild(newDom);
return newDom;
}
// Text changed
if (typeof oldVNode === "string" || typeof newVNode === "string") {
if (oldVNode !== newVNode) {
const newDom = render(newVNode, document.createElement('div'));
parent.replaceChild(newDom, dom);
return newDom;
}
return dom;
}
// Type changed (div → span)
if (oldVNode.type !== newVNode.type) {
const newDom = render(newVNode, document.createElement('div'));
parent.replaceChild(newDom, dom);
return newDom;
}
// Update props
updateProps(dom, oldVNode.props || {}, newVNode.props || {});
// Diff children
const oldChildren = oldVNode.children || [];
const newChildren = newVNode.children || [];
const maxLength = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLength; i++) {
diff(
oldChildren[i],
newChildren[i],
dom.childNodes[i],
dom,
i
);
}
return dom;
}
function updateProps(dom, oldProps, newProps) {
// Remove old props
Object.keys(oldProps).forEach(key => {
if (!(key in newProps)) {
if (key.startsWith("on")) {
const event = key.substring(2).toLowerCase();
dom.removeEventListener(event, oldProps[key]);
} else {
dom[key] = null;
}
}
});
// Update/add new props
Object.keys(newProps).forEach(key => {
if (oldProps[key] !== newProps[key]) {
if (key.startsWith("on")) {
const event = key.substring(2).toLowerCase();
if (oldProps[key]) {
dom.removeEventListener(event, oldProps[key]);
}
dom.addEventListener(event, newProps[key]);
} else if (key === "className") {
dom.className = newProps[key];
} else if (key !== "children") {
dom[key] = newProps[key];
}
}
});
}
This is where I felt smart. I had built React's core! I opened DevTools and watched only the changed <div> update. Beautiful.
Then I tested it with a list of 10,000 items.
The browser froze for 3 seconds.
The Problem with Recursive Diffing (Why My React Sucks)
My diff algorithm is synchronous and blocking.
User clicks
↓
setState called
↓
Diff algorithm runs (BLOCKING)
↓
...still diffing...
↓
...still diffing...
↓
...still diffing...
↓
DOM updates (3 seconds later)
During those 3 seconds:
- Browser can't respond to user input
- Animations freeze
- Page feels broken
This is a fundamental problem with recursive algorithms. Once you start, you can't stop until it's done.
Real React doesn't have this problem. Why?
Fiber.
What Fiber Actually Is (Not What You Think)
Most people think Fiber is "React's new reconciler." That's not wrong, but it misses the point.
Fiber is a complete reimplementation of React's core algorithm to make rendering interruptible.
Pre-Fiber React (Stack Reconciler):
- Recursive
- Synchronous
- All-or-nothing
- Blocks main thread
Fiber React:
- Iterative
- Pausable
- Resumable
- Priority-based
- Non-blocking
The key insight: reconciliation and rendering are separate phases.
The Two-Phase Commit Model
React Fiber splits work into two phases:
Phase 1: Render Phase (Interruptible)
- Build the Fiber tree
- Calculate diffs
- Figure out what needs to change
- Can be paused, resumed, or aborted
Phase 2: Commit Phase (Synchronous)
- Apply changes to DOM
- Run effects
- Cannot be interrupted (must be fast)
This separation is brilliant. The slow part (diffing thousands of components) is pausable. The fast part (DOM mutations) happens in one synchronous batch.
Understanding Fiber: The Linked List Data Structure
Here's what blew my mind: Fiber is a linked list, not a tree.
Each Fiber node looks like this:
{
type: "div", // Component type
props: {...}, // Props
stateNode: domNode, // Reference to actual DOM node
// Linked list pointers
child: fiberNode, // First child
sibling: fiberNode, // Next sibling
return: fiberNode, // Parent
// Work tracking
alternate: oldFiber, // Previous version (for diffing)
effectTag: "UPDATE", // What kind of work (PLACEMENT, UPDATE, DELETION)
// Scheduling
lanes: 0b0001, // Priority lanes (more on this later)
pendingProps: {...}, // Props for next render
memoizedProps: {...}, // Props from last render
memoizedState: {...}, // State from last render
// Hooks
memoizedState: hooksList // Linked list of hooks
}
Why a linked list instead of a tree?
Because you can pause iteration of a linked list.
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (nextUnitOfWork) {
// More work to do, schedule next chunk
requestIdleCallback(workLoop);
} else {
// Work done, commit to DOM
commitRoot();
}
}
function performUnitOfWork(fiber) {
// 1. Do work for this fiber
if (fiber.type instanceof Function) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
// 2. Return next unit of work
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
return null;
}
This is the core of Fiber. Work is broken into units. Each unit is small enough that the browser stays responsive.
Implementing My Own Fiber-Based React
Here's a simplified version I built:
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
function render(element, container) {
wipRoot = {
stateNode: container,
props: { children: [element] },
alternate: currentRoot
};
deletions = [];
nextUnitOfWork = wipRoot;
}
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
// Create DOM node if needed
if (!fiber.stateNode && fiber.type) {
fiber.stateNode = createDom(fiber);
}
// Create fibers for children
const elements = fiber.props.children;
reconcileChildren(fiber, elements);
// Return next unit of work (child, sibling, or uncle)
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
}
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate?.child;
let prevSibling = null;
while (index < elements.length || oldFiber) {
const element = elements[index];
let newFiber = null;
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) {
// Update existing fiber
newFiber = {
type: oldFiber.type,
props: element.props,
stateNode: oldFiber.stateNode,
return: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE"
};
}
if (element && !sameType) {
// New element
newFiber = {
type: element.type,
props: element.props,
stateNode: null,
return: wipFiber,
alternate: null,
effectTag: "PLACEMENT"
};
}
if (oldFiber && !sameType) {
// Delete old fiber
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) oldFiber = oldFiber.sibling;
if (index === 0) {
wipFiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
let domParentFiber = fiber.return;
while (!domParentFiber.stateNode) {
domParentFiber = domParentFiber.return;
}
const domParent = domParentFiber.stateNode;
if (fiber.effectTag === "PLACEMENT" && fiber.stateNode) {
domParent.appendChild(fiber.stateNode);
} else if (fiber.effectTag === "UPDATE" && fiber.stateNode) {
updateDom(fiber.stateNode, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
Now when I tested with 10,000 items, the browser stayed responsive. I could type, scroll, click—all while React was diffing in the background.
This is the power of Fiber.
Priority Scheduling: Why Some Updates Are Faster Than Others
But Fiber goes deeper. Not all updates are equal.
User typing in an input? High priority. Needs to feel instant.
Data fetching result? Low priority. Can wait a few frames.
React Fiber uses lanes (bit masks) to track priority:
const SyncLane = 0b0000000000000000000000000000001;
const InputContinuousLane = 0b0000000000000000000000000000100;
const DefaultLane = 0b0000000000000000000000000010000;
const TransitionLane = 0b0000000000000000000001000000000;
const IdleLane = 0b0100000000000000000000000000000;
When you call setState, React assigns a lane based on how the update was triggered:
function scheduleUpdateOnFiber(fiber, lane) {
fiber.lanes |= lane; // Bitwise OR to merge lanes
let node = fiber;
while (node !== null) {
node.childLanes |= lane;
node = node.return;
}
ensureRootIsScheduled(root);
}
During the render phase, React picks the highest-priority lane:
function getNextLanes(root) {
const pendingLanes = root.pendingLanes;
if (pendingLanes === 0) return NoLanes;
// Check lanes from highest to lowest priority
const nextLanes = getHighestPriorityLanes(pendingLanes);
return nextLanes;
}
This is why typing feels instant even when a heavy component is rendering in the background. React interrupts the low-priority render to handle your keystroke.
Concurrent Rendering: The Future is Already Here
With Fiber's architecture in place, React can do something wild: render multiple versions of the UI simultaneously.
// Low priority update starts
startTransition(() => {
setSearchResults(expensiveFilter(query));
});
// User types (high priority)
setQuery(e.target.value); // This interrupts the above!
React can:
- Start rendering the expensive search results
- Pause when user types
- Render the updated input (fast)
- Resume or abandon the search results render
This is concurrent rendering. It's not parallelism (still single-threaded), it's interleaving.
Hooks: The Fiber Implementation
Now hooks make sense. They're just a linked list on the Fiber node.
let currentFiber = null;
let hookIndex = null;
function useState(initial) {
const oldHook = currentFiber.alternate?.memoizedState?.[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: []
};
// Process queued updates
oldHook?.queue.forEach(action => {
hook.state = typeof action === 'function' ? action(hook.state) : action;
});
const setState = (action) => {
hook.queue.push(action);
// Schedule re-render
wipRoot = {
stateNode: currentRoot.stateNode,
props: currentRoot.props,
alternate: currentRoot
};
nextUnitOfWork = wipRoot;
deletions = [];
};
currentFiber.memoizedState = currentFiber.memoizedState || [];
currentFiber.memoizedState[hookIndex] = hook;
hookIndex++;
return [hook.state, setState];
}
This is why:
- Hooks must be called in the same order every render (they're indexed)
- You can't call hooks conditionally (would break the index)
- Each component instance has its own hook list
Keys: Why They're Critical to Fiber
Keys aren't just a performance hint. They're how Fiber maintains component identity.
Without keys:
{items.map(item => <ExpensiveComponent data={item} />)}
When list order changes, React sees:
Position 0: <ExpensiveComponent data={A} /> → <ExpensiveComponent data={B} />
It thinks component stayed in place but props changed. So it:
- Keeps the same Fiber node
- Updates props
- Re-renders component
- Loses internal state
With keys:
{items.map(item => <ExpensiveComponent key={item.id} data={item} />)}
React sees:
Component with key "A" moved from position 0 to position 1
It:
- Moves the Fiber node
- Moves the DOM node
- Preserves component state
Keys give Fiber nodes stable identity across renders.
Effect Timing: Why useEffect Is Not componentDidMount
This confused me for months. Then I understood the commit phase.
Render Phase (Interruptible)
↓
Commit Phase (Synchronous)
1. Before Mutation
2. Mutation (DOM updates)
3. Layout Effects (useLayoutEffect)
4. After Mutation
↓
Passive Effects (useEffect) - AFTER paint
useEffect runs after the browser paints. This is why it doesn't block rendering.
useLayoutEffect runs before paint, synchronously. Good for measuring DOM, bad for heavy work.
function commitRoot() {
const finishedWork = wipRoot;
commitBeforeMutationEffects(finishedWork);
commitMutationEffects(finishedWork); // DOM updates
root.current = finishedWork;
commitLayoutEffects(finishedWork); // useLayoutEffect
// Schedule passive effects to run after paint
schedulePassiveEffects(finishedWork); // useEffect
}
Batching: Why Multiple setState Calls Don't Cause Multiple Renders
Pre-React 18, batching only worked in event handlers:
function handleClick() {
setCount(c => c + 1); // Batched
setFlag(f => !f); // Batched
// Only one re-render
}
fetch('/api').then(() => {
setCount(c => c + 1); // Not batched (separate render)
setFlag(f => !f); // Not batched (separate render)
});
React 18 with automatic batching:
fetch('/api').then(() => {
setCount(c => c + 1); // Batched!
setFlag(f => !f); // Batched!
// One re-render
});
How? React batches updates within the same event loop tick:
let isBatchingUpdates = false;
const updateQueue = [];
function scheduleUpdate(fiber, update) {
updateQueue.push({ fiber, update });
if (!isBatchingUpdates) {
isBatchingUpdates = true;
queueMicrotask(() => {
flushUpdates();
isBatchingUpdates = false;
});
}
}
function flushUpdates() {
updateQueue.forEach(({ fiber, update }) => {
applyUpdate(fiber, update);
});
updateQueue.length = 0;
performWork();
}
What I'm Still Missing (And Why It Matters)
Building mini React taught me a ton, but real React is orders of magnitude more sophisticated:
Context propagation
- How does
useContextbail out of renders? - Context value change propagation through Fiber tree
Suspense
- Throwing promises during render
- Boundary detection and fallback rendering
- Integration with concurrent features
Error boundaries
- Error capture in Fiber tree
- Error recovery and re-rendering
Server Components
- Serialization format
- Client/server boundary
- Streaming SSR with selective hydration
Transitions
-
startTransitionAPI - Keeping UI responsive during heavy updates
- Marking updates as non-urgent
Offscreen rendering
- Pre-rendering hidden content
- Reusing rendered trees when showing/hiding
Automatic batching
- Cross-tick batching in React 18
- Integration with lanes
Time slicing
- Breaking work into 5ms chunks
-
shouldYieldlogic - Integration with browser's frame budget
Each of these deserves its own deep dive. But they all build on Fiber's foundation.
What This Actually Changed for Me
Building this shifted how I think about the code I write every day.
Before:
// Just make it work
setItems([...items, newItem]);
After:
// Hmm, what's the reconciliation cost here?
// Am I creating unnecessary work for Fiber?
// Should this be memoized to preserve object identity?
setItems(prev => [...prev, newItem]);
I'm not claiming to be an expert now. But I think about:
- Render phase cost (how expensive is this diff?)
- Commit phase cost (how many DOM updates?)
- Component identity (are my keys stable?)
- Re-render boundaries (where does memoization actually help?)
It's made me better at code reviews. When I see performance issues, I can actually explain why they're happening, not just cargo-cult best practices.
If You're Looking to Level Up (Mid to Senior Track)
Most React developers at my level know the API really well. We can build features fast and write clean component code. But understanding the internals? That's what separates mid-level from senior.
Topics that show deeper understanding:
- Fiber reconciliation (how updates are actually scheduled)
- Priority lanes (why some updates feel faster than others)
- Double buffering (current tree vs work-in-progress tree)
- Effect timing (why useEffect runs after paint)
- Concurrent features (how time-slicing actually works)
Real interview question I got recently:
"Why does
useEffectrun after paint whileuseLayoutEffectruns before?"
I used to just know "that's what the docs say." Now I understand it's the commit phase pipeline and browser rendering lifecycle. That changes how you answer technical questions.
Try This Yourself
You don't need to build a full framework. Even a 200-line version teaches you:
- Why reconciliation is expensive
- Why keys matter for performance
- How batching works
- Why Fiber enables concurrent features
The code isn't the point. The mental model is.
Understanding the tools you use every day makes you a better engineer. Not just a better React developer—a better engineer.
Further Reading
If this interested you:
React Source Code
Articles That Helped Me
- Lin Clark's cartoon guide to Fiber
- Dan Abramov's overreacted.io (especially hooks implementation)
- React RFC documents on concurrent mode
My Other Deep Dives
- How Virtual DOM Actually Works
- The Complete Guide to React Rendering Behavior
- React Compiler: What It Does and Why It Matters
Connect with me → https://www.linkedin.com/in/munna-thakur-frontend-developer-2854b5243/
Top comments (0)