DEV Community

Munna Thakur
Munna Thakur

Posted on

I Built React from Scratch and Discovered Why Fiber Changes Everything


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);
}
Enter fullscreen mode Exit fullscreen mode

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];
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

React can:

  1. Start rendering the expensive search results
  2. Pause when user types
  3. Render the updated input (fast)
  4. 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];
}
Enter fullscreen mode Exit fullscreen mode

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} />)}
Enter fullscreen mode Exit fullscreen mode

When list order changes, React sees:

Position 0: <ExpensiveComponent data={A} /> → <ExpensiveComponent data={B} />
Enter fullscreen mode Exit fullscreen mode

It thinks component stayed in place but props changed. So it:

  1. Keeps the same Fiber node
  2. Updates props
  3. Re-renders component
  4. Loses internal state

With keys:

{items.map(item => <ExpensiveComponent key={item.id} data={item} />)}
Enter fullscreen mode Exit fullscreen mode

React sees:

Component with key "A" moved from position 0 to position 1
Enter fullscreen mode Exit fullscreen mode

It:

  1. Moves the Fiber node
  2. Moves the DOM node
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode

React 18 with automatic batching:

fetch('/api').then(() => {
  setCount(c => c + 1);   // Batched!
  setFlag(f => !f);       // Batched!
  // One re-render
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 useContext bail 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

  • startTransition API
  • 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
  • shouldYield logic
  • 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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 useEffect run after paint while useLayoutEffect runs 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:

  1. Why reconciliation is expensive
  2. Why keys matter for performance
  3. How batching works
  4. 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 mehttps://www.linkedin.com/in/munna-thakur-frontend-developer-2854b5243/

Top comments (0)