Build MiniReact: A Fiber Rendering Engine
Build a React-like framework from scratch with Fiber, hooks, and reconciliation.
Introduction
30 Second Summary
Every time you click a button and a counter updates on screen, something invisible decides what changed and redraws just that piece of the page. Frameworks like React handle this for you, but the machinery underneath is rarely seen.
In this project, you will build a complete rendering engine from scratch called MiniReact. It mirrors the same Fiber architecture that powers the real React library, all in a single HTML file with no dependencies.
What You'll Build
MiniReact is a minimal rendering framework that creates, updates, and deletes DOM elements using a Fiber-based reconciler, an interruptible work loop, and a functional useState hook.
By the end of this project, you'll have:
- A Fiber-based rendering engine that creates, updates, and deletes DOM elements using the same architecture React uses internally.
- A functional useState hook that maintains independent state across re-renders and demonstrates why hook call order matters.
- The mental model React's core team uses: Fiber nodes, the interruptible work loop, render vs commit phases, and reconciliation by type.
- Secret Mission: Implement useEffect with dependency tracking and cleanup functions.
Are there any prerequisites?
You should be comfortable with JavaScript (closures, recursion, tree structures) and have experience using React (components, hooks, JSX). You do not need any prior knowledge of React's internals.
Set Up the Project File
The entire MiniReact framework lives in a single HTML file with zero dependencies. No npm, no build tools, no bundlers.
In this step, you'll create the project file, lay down the HTML skeleton, and add a browser compatibility polyfill that your rendering engine needs to run in all browsers.
In this step, get ready to:
- Create the project folder and index.html file.
- Add the requestIdleCallback polyfill for browser compatibility.
- Verify the file loads cleanly in Chrome DevTools.
Create the project folder and HTML file
Every project needs a home. You'll create a dedicated folder on your Desktop so you can find it easily later.
- In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, next to the 1 key).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir minireact
cd minireact
- Create the index.html file by running:
touch index.html
- Confirm the file exists by running:
ls
You should see index.html in the output.
- Open the minireact folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the minireact folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, click index.html to open it in the editor.
This file will contain your entire framework. Paste the HTML skeleton below into index.html:
- Add the HTML skeleton to index.html by pasting this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniReact</title>
</head>
<body>
<div id="root"></div>
<script>
</script>
</body>
</html>
What does this code do?
- The <div id="root"> is the container where your framework will mount its entire UI. This mirrors React's pattern of rendering into a single root DOM node.
- The empty <script> tag is where all your framework code will live. No separate files, no imports.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Don't see the file in the Explorer sidebar?
Make sure you opened the minireact folder itself, not its parent (Desktop). The Explorer sidebar should show index.html at the top level.
If the sidebar is hidden, press Cmd+B (macOS) or Ctrl+B (Windows) to toggle it.
Help me open my project folder in VS Code
Add the requestIdleCallback polyfill
Your rendering engine uses requestIdleCallback to schedule work when the browser is idle. Safari does not support this API by default, so a polyfill ensures your code works everywhere.
- In index.html, find the empty <script> tag and paste the following polyfill inside it:
// Polyfill for requestIdleCallback (MDN-documented pattern)
window.requestIdleCallback = window.requestIdleCallback || function (handler) {
const startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
What does this polyfill do?
The requestIdleCallback API lets the browser call your function only when it has free time. Your rendering engine will use this to process one fiber at a time without blocking user interactions.
The polyfill simulates this using setTimeout with a 50ms budget. If the browser supports the real API, the polyfill does nothing.
- Save index.html.
Seeing a syntax error?
Make sure the polyfill code sits between the opening <script> and closing </script> tags. Code placed outside the script tags won't run as JavaScript.
Check for missing closing braces or parentheses. The structure has nested functions, so every { needs a matching }.
Help me fix a syntax error in my polyfill code
Open the file in Chrome and verify
You'll use Chrome's DevTools console throughout this project to verify each piece of your framework as you build it.
- Switch to Chrome.
- Drag index.html from your Desktop's minireact folder into the Chrome window. Alternatively, press Cmd+O (macOS) or Ctrl+O (Windows) and navigate to the file.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Console tab in DevTools.
You should see a blank white page with no errors in the Console. The page is empty because you have not rendered anything yet.
Seeing errors in the Console?
If you see a red error message, open index.html in VS Code and check that your <script> tag is properly closed. Every opening tag needs a matching closing tag.
If the page shows raw HTML code instead of a blank page, make sure the file extension is .html and you opened it in the browser (not in a text editor tab).
Help me fix console errors in my index.html
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on.
ⓧ I'd like to double check the full code
Here's what your complete index.html should look like at this point:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniReact</title>
</head>
<body>
<div id="root"></div>
<script>
// Polyfill for requestIdleCallback (MDN-documented pattern)
window.requestIdleCallback = window.requestIdleCallback || function (handler) {
const startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
</script>
</body>
</html>
Your project file is ready and running cleanly in the browser. Next up, you'll build the createElement function that describes UI as plain JavaScript objects.
Build createElement and the Virtual Element Tree
Your environment is ready. Now it is time to build the foundation of your rendering engine: the functions that describe UI as plain JavaScript objects.
In React, when you write JSX like <div id="foo">hello</div>, a compiler transforms it into createElement("div", { id: "foo" }, "hello") calls. These calls return plain objects that form a virtual element tree. Your first job is to implement that transformation layer, then build the DOM manipulation functions that will eventually turn those objects into real browser elements.
In this step, get ready to:
- Build createElement and createTextElement to describe UI as objects.
- Build createDom and updateDom to create and patch real DOM nodes from those objects.
- Wire up a render function that schedules the first unit of work.
Implement createElement and createTextElement
Every UI description in your framework starts with a call to createElement. It takes a type (like "div" or a function component), props, and any number of children. String or number children get wrapped in a special TEXT_ELEMENT type so every child has the same shape.
- In index.html, find the closing }; of the requestIdleCallback polyfill, then add the following code below it:
// === ELEMENT CREATION ===
// Returns a virtual element object describing a UI node
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
// Wraps plain strings/numbers as TEXT_ELEMENT objects
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
What does this code do?
createElement builds a plain object with a type (what kind of element) and props (its attributes plus children). This is exactly the object shape that JSX compiles down to.
createTextElement handles raw strings like "hello". It wraps them in the same object shape with a special TEXT_ELEMENT type so that every node in the tree is uniform. The framework never has to check "is this a string or an object?" downstream.
- type tells the framework what DOM element to create (or which function to call for components).
- props.children is always an array of element objects, never raw strings.
- The rest operator ...children collects all arguments after props into a single array.
- Save index.html.
- Switch to Chrome and refresh the page. Open the Console tab in DevTools.
- Test your function by typing this in the console:
createElement("div", { id: "foo" }, "hello")
- You should see an object with type: "div", props.id: "foo", and a children array containing one TEXT_ELEMENT object.
Seeing 'createElement is not defined'?
- Make sure you saved index.html and refreshed the browser after saving.
- Check that your code is inside the <script> tag, not outside it.
- Look for red error text in the Console. A missing bracket or comma in the polyfill above will prevent all code below it from loading.
Help me debug why createElement is not defined in my browser console
Implement createDom and updateDom
Virtual elements describe what the UI should look like. Now you need functions that turn those descriptions into real browser DOM nodes and keep their properties in sync. These two functions handle the bridge from virtual tree to actual screen pixels.
- Below the createTextElement function, add the createDom function and four helper declarations:
// === DOM OPERATIONS ===
// Creates a real DOM node from a fiber's type
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
// Helpers to categorize prop keys
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);
What does this code do?
createDom checks the fiber's type. If it is TEXT_ELEMENT, it makes a text node. Otherwise it creates a regular HTML element. It then delegates all property assignment to updateDom (which you will add next).
The four helpers classify each prop key so updateDom knows how to handle it. Props starting with on are event handlers. Everything else (except children) is a regular DOM property.
- Directly below the helpers, add the updateDom function that diffs old and new props to patch a DOM node:
// Diffs prevProps vs nextProps and patches the DOM node
function updateDom(dom, prevProps, nextProps) {
// Remove old or changed event listeners
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = "";
});
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name];
});
// Add new event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
How does updateDom handle events?
Event handlers follow a naming convention: onClick becomes a click event listener, onInput becomes input. The function strips the on prefix and lowercases the rest.
The four-step flow handles every prop change scenario: remove stale event listeners, clear deleted properties, assign new or changed properties, then attach new event listeners. This same diffing approach is what makes React efficient at DOM updates.
- Save index.html and refresh Chrome.
- Test createDom in the console by typing:
createDom({ type: "div", props: { id: "test", children: [] } })
- You should see a <div> element returned with id="test" set on it.
Getting 'updateDom is not defined'?
- Confirm that updateDom is declared below the four helper constants, not above them. The helpers use const and are not hoisted, so they must appear first in your file.
- Check for mismatched curly braces in the updateDom function. Each .forEach block must have matching }) at the end.
Help me figure out why createDom or updateDom is throwing an error
Wire up the render function
You have functions to describe elements and create DOM nodes. The missing piece is a render function that kicks off the whole process. For now, it just creates the initial root fiber and points the framework at the first unit of work. Nothing will render yet because there is no work loop to process fibers.
- Below the updateDom function, add the global state variables and the render function:
// === GLOBALS ===
// Framework state: tracks what to work on next
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
let wipFiber = null;
let hookIndex = null;
// Kicks off a render by creating the root fiber
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
What does this code do?
The global variables track the framework's internal state across renders. wipRoot (work-in-progress root) is the fiber tree currently being built. currentRoot is the tree that is already on screen. nextUnitOfWork points to the next fiber the work loop should process.
The render function creates a root fiber whose dom is the container element (<div id="root">) and whose children contain the element tree you want to display. Setting nextUnitOfWork is what tells the (yet-to-be-built) work loop to start processing.
- alternate links the new tree to the old one. This is called double buffering. The reconciler will compare these two trees to figure out what changed.
- deletions collects fibers that should be removed from the DOM during commit.
- Save index.html and refresh Chrome.
- Test the full pipeline in the console by typing:
render(createElement("h1", null, "test"), document.getElementById("root"))
- Type wipRoot in the console. You should see a fiber object with dom pointing to the #root div and props.children containing your element. The page stays blank because no work loop processes the fiber yet.
wipRoot shows null?
- Check that render is declared after the global let statements. If wipRoot is declared after render, the assignment inside render targets an undefined variable.
- Make sure you typed the render(...) call in the console, not in the file. Adding it to the file without a work loop would just set wipRoot once, with no loop to clear it.
Help me understand why wipRoot is null after calling render
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html so far. Make sure yours matches:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniReact</title>
</head>
<body>
<div id="root"></div>
<script>
// Polyfill for requestIdleCallback (MDN-documented pattern)
window.requestIdleCallback = window.requestIdleCallback || function (handler) {
const startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
// === ELEMENT CREATION ===
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
// === DOM OPERATIONS ===
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);
function updateDom(dom, prevProps, nextProps) {
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = "";
});
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name];
});
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
// === GLOBALS ===
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
let wipFiber = null;
let hookIndex = null;
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
</script>
</body>
</html>
You now have the complete element-creation and DOM-manipulation layer. Next up, you will build the Fiber work loop that processes one node at a time and yields to the browser between each unit of work.
Build the Fiber Work Loop
You have the building blocks for describing UI and creating DOM nodes. The missing piece is the engine that actually processes your element tree.
Real React does not render a tree in one synchronous pass. It processes one fiber at a time, yielding control back to the browser between each unit of work so animations and input stay smooth. In this step, you will build that same interruptible work loop using requestIdleCallback.
In this step, get ready to:
- Implement the work loop that processes fibers one at a time during browser idle time.
- Implement the fiber processing function that handles both function and host components.
- Wire up a test element and verify fibers are being processed in the console.
Implement the work loop
The browser gives your code a time budget during each idle frame. Your work loop needs to process as many fibers as it can within that budget, then yield so the browser can handle animations and user input.
- In index.html, find the render function. Add the following workLoop function directly above it:
function workLoop(deadline) {
// Keep processing fibers while there is work and time remains
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
// When all fibers are processed and a WIP root exists, commit to DOM
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
// Always reschedule so the loop keeps running
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
What does this code do?
The browser calls workLoop whenever the main thread is idle, passing a deadline object. The deadline.timeRemaining() method returns how many milliseconds are left before the browser needs the thread back.
The while loop processes one fiber per iteration. After each fiber, it checks whether time remains. If the deadline is nearly up (less than 1ms left), it sets shouldYield to true and breaks out, letting the browser paint and handle events.
At the end, requestIdleCallback(workLoop) reschedules itself so the loop runs continuously. This is the same cooperative scheduling pattern React uses internally.
Why requestIdleCallback instead of a custom scheduler?
React's production scheduler uses MessageChannel for finer-grained control. For learning purposes, requestIdleCallback teaches the same concept (cooperative time-slicing) using a browser-native API you can inspect in DevTools. The polyfill you added in Step 1 ensures this works in Safari.
Implement performUnitOfWork and its helpers
Each fiber represents one unit of work. The performUnitOfWork function processes a single fiber and returns the next one to process using a depth-first linked-list traversal: child first, then sibling, then back up via the return pointer.
- Add the performUnitOfWork function below the requestIdleCallback(workLoop) line:
function performUnitOfWork(fiber) {
// Determine if this fiber is a function component or a host (DOM) component
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
// Return the next fiber: child first
if (fiber.child) return fiber.child;
// No child? Try sibling, then uncle (parent's sibling)
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
}
What does this code do?
This function handles one fiber and decides what to do with it based on its type. Function components (like your Counter app later) get called as functions. Host components (like div and h1) get turned into actual DOM nodes.
The traversal at the bottom is a singly-linked-list depth-first walk. It visits child, then sibling, then climbs back up via return to find the next sibling up the tree. This is the exact same traversal pattern React uses internally. No recursion, no stack overflow risk.
- Add the two helper functions and the reconcileChildren stub directly below performUnitOfWork:
function updateFunctionComponent(fiber) {
// Set up hook context for this fiber
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
// Call the component function to get its children
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function updateHostComponent(fiber) {
// Create the DOM node if it doesn't exist yet
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// Reconcile this fiber's children
reconcileChildren(fiber, fiber.props.children);
}
// Placeholder - will be implemented in the next step
function reconcileChildren(wipFiber, elements) {}
What do these helpers do?
The updateFunctionComponent sets up the hooks context (wipFiber, hookIndex, empty hooks array) then calls the component function to produce its element tree. This is why hooks must be called in the same order every render. The hookIndex maps each useState call to a position in the array.
The updateHostComponent creates the actual DOM node (using createDom from the previous step) and passes its children to reconciliation.
The reconcileChildren stub is empty for now. Without it, the tree cannot link child fibers, so only the root fiber will process. You will implement the full reconciliation algorithm in the next step.
- Save index.html.
Seeing a syntax error?
Check that your workLoop function appears ABOVE render and that performUnitOfWork appears BELOW the requestIdleCallback(workLoop) call. JavaScript hoists function declarations, so order technically does not matter, but keeping them in execution order helps you debug.
Also verify every opening brace { has a matching closing brace }. A missing brace in performUnitOfWork's while loop is a common slip.
help me fix a syntax error in my MiniReact work loop
Test the work loop with a console log
The work loop infrastructure is in place. Now you will add a log statement and a test render to confirm the loop is executing.
- In index.html, find the first line inside performUnitOfWork:
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
- Add a console.log line between the opening brace and the const declaration, so the top of the function looks like this:
function performUnitOfWork(fiber) {
console.log("Processing fiber:", fiber.type);
const isFunctionComponent = fiber.type instanceof Function;
- Now add the test render call at the very bottom of the <script> tag, below the render function definition:
render(createElement("div", null, createElement("h1", null, "Hello")), document.getElementById("root"));
What does this test do?
This creates a simple virtual element tree: a div containing an h1 containing the text "Hello". The render call sets up the root fiber and assigns it to nextUnitOfWork. On the next idle frame, the work loop picks it up and begins processing.
- Save index.html and refresh your browser.
- Open the Console tab in DevTools. You should see a log line that says Processing fiber: followed by the root fiber's type. The page remains blank because reconcileChildren is empty (no child fibers are linked yet) and commitRoot is not implemented.
Why is the page still blank?
Two pieces are missing. First, reconcileChildren is a stub. It does not create child fibers or link them with child/sibling/return pointers, so the work loop only processes the root fiber before running out of work.
Second, commitRoot does not exist yet. Even after fibers are processed, nothing writes to the DOM until the commit phase is built. You will implement both in the next step.
Not seeing any console output?
Make sure the render(...) call is at the bottom of the script, after all function definitions. If it runs before workLoop is scheduled, nextUnitOfWork may never get picked up.
Also check for a ReferenceError mentioning commitRoot in the console. This is expected since commitRoot is called by the work loop but not defined yet. The Processing fiber: log should still appear before the error.
help me debug why my MiniReact work loop is not producing console output
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniReact</title>
</head>
<body>
<div id="root"></div>
<script>
window.requestIdleCallback = window.requestIdleCallback || function (handler) {
const startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: { nodeValue: text, children: [] },
};
}
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);
function updateDom(dom, prevProps, nextProps) {
Object.keys(prevProps).filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => { dom.removeEventListener(name.toLowerCase().substring(2), prevProps[name]); });
Object.keys(prevProps).filter(isProperty).filter(isGone(prevProps, nextProps))
.forEach(name => { dom[name] = ""; });
Object.keys(nextProps).filter(isProperty).filter(isNew(prevProps, nextProps))
.forEach(name => { dom[name] = nextProps[name]; });
Object.keys(nextProps).filter(isEvent).filter(isNew(prevProps, nextProps))
.forEach(name => { dom.addEventListener(name.toLowerCase().substring(2), nextProps[name]); });
}
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
let wipFiber = null;
let hookIndex = null;
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) {
console.log("Processing fiber:", fiber.type);
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
}
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
function reconcileChildren(wipFiber, elements) {}
function render(element, container) {
wipRoot = {
dom: container,
props: { children: [element] },
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
render(createElement("div", null, createElement("h1", null, "Hello")), document.getElementById("root"));
</script>
</body>
</html>
Your work loop is running and processing fibers. Next up, you will implement the reconciliation algorithm that diffs old and new trees, plus the commit phase that actually writes changes to the DOM.
Implement Reconciliation and the Commit Phase
Your work loop processes fibers one at a time and the console proves it. But nothing appears on screen because MiniReact has no way to decide what changed or apply those changes to the DOM.
This step builds the two missing pieces: a reconciler that compares old and new trees to determine the minimum set of DOM operations, and a commit phase that applies those operations in one batch after the render phase finishes.
In this step, get ready to:
- Implement the reconciliation algorithm that diffs old fibers against new elements.
- Build the commit phase that batch-applies DOM mutations after the render phase completes.
- Test the full pipeline by rendering and re-rendering elements to the screen.
Implement the reconcileChildren function
Reconciliation is the algorithm that compares the previous fiber tree against the new element tree to figure out the minimum DOM changes needed.
For each position in the children list, the reconciler compares element types. Same type means reuse the DOM node and update its props. Different type with a new element means create a fresh node. An old fiber with no matching element means delete it.
- In index.html, find the empty reconcileChildren function and replace it with this implementation:
function reconcileChildren(wipFiber, elements) {
let index = 0;
// Get the old fiber tree for comparison (double buffering)
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
// Walk both lists simultaneously
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
// Compare types to decide what to do
const sameType = oldFiber && element && element.type === oldFiber.type;
// Same type: reuse DOM node, just update props
if (sameType) {
newFiber = { type: oldFiber.type, props: element.props, dom: oldFiber.dom, return: wipFiber, alternate: oldFiber, effectTag: "UPDATE" };
}
// New element with different type: create new DOM node
if (element && !sameType) {
newFiber = { type: element.type, props: element.props, dom: null, return: wipFiber, alternate: null, effectTag: "PLACEMENT" };
}
// Old fiber with no match: mark for deletion
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
// Advance the old fiber pointer
if (oldFiber) oldFiber = oldFiber.sibling;
// Build the linked list: first child goes on .child, rest go on .sibling
if (index === 0) wipFiber.child = newFiber;
else if (element) prevSibling.sibling = newFiber;
prevSibling = newFiber;
index++;
}
}
What does this code do?
This function walks two lists at the same time: the new elements you want to render and the old fibers from the previous render.
- effectTag: "UPDATE" tells the commit phase to keep the existing DOM node and just update its properties.
- effectTag: "PLACEMENT" tells it to create and insert a brand new DOM node.
- effectTag: "DELETION" tells it to remove the old node from the DOM.
- The alternate pointer connects the new fiber to its old version. This is the double buffering technique React uses to compare trees without copying.
Why does this mirror React's real reconciler?
React's reconciliation uses the same heuristic: same position plus same type equals update in place. Different type means unmount the old subtree and mount a new one. This simple rule avoids expensive tree-diffing algorithms.
- Save index.html.
- You won't see a change yet. The reconciler marks what needs to happen, but the commit phase (which actually touches the DOM) does not exist yet.
Implement the commit phase
The commit phase is where DOM mutations actually happen. It runs synchronously after the render phase finishes, ensuring the user never sees a half-built UI.
- In index.html, find the render function. Add the following three functions directly above it:
function commitRoot() {
// Process deletions first so removed nodes don't interfere
deletions.forEach(commitWork);
// Then commit the new/updated fiber tree
commitWork(wipRoot.child);
// Save as "current" tree for next render's comparison
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
// Walk up to find the nearest ancestor with a real DOM node
let domParentFiber = fiber.return;
while (!domParentFiber.dom) domParentFiber = domParentFiber.return;
const domParent = domParentFiber.dom;
// Apply the correct DOM operation based on effectTag
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
return;
}
// Recurse into children and siblings
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
// If this fiber has a DOM node, remove it directly
if (fiber.dom) domParent.removeChild(fiber.dom);
// Otherwise dig into children (needed for function components)
else commitDeletion(fiber.child, domParent);
}
What does each function do?
- commitRoot is the entry point. It processes deletions first, then walks the new tree. After committing, it saves the tree as currentRoot so the next render can compare against it.
- commitWork reads each fiber's effectTag and performs the matching DOM operation: appendChild for PLACEMENT, updateDom for UPDATE, and removeChild for DELETION.
- commitDeletion handles function components that don't have their own DOM node. It walks down into children until it finds a real DOM node to remove.
Why walk up to find the DOM parent?
Function components produce fibers with no DOM node. When committing a child of a function component, you need to climb the tree via return pointers until you reach an ancestor that has an actual DOM element to attach to.
- Save index.html and refresh your browser.
- You should see Hello rendered on the page as a heading. The reconciler marked every fiber as PLACEMENT (first render has no old tree), and the commit phase appended them to the DOM.
Page still blank?
- Open DevTools and check for errors. A common mistake is a typo in effectTag (it must be spelled exactly as PLACEMENT, UPDATE, or DELETION).
- Make sure commitRoot is defined before the workLoop calls it. If it is placed after render, JavaScript hoisting handles functions fine, but double check the name matches exactly.
- Verify that reconcileChildren replaces the empty stub, not sits alongside it. You should have only one function with that name.
Still stuck? Help me debug why my MiniReact commit phase isn't rendering elements to the DOM.
Test re-rendering and DOM reuse
A rendering engine that only works once is not useful. The real power of reconciliation is that calling render again reuses existing DOM nodes instead of rebuilding everything from scratch.
- In index.html, find this line inside performUnitOfWork:
console.log("Processing fiber:", fiber.type);
- Delete that line. The debug logging has served its purpose and would clutter the console during testing.
- Save index.html and refresh your browser. You should still see Hello on the page, with a clean console.
- Test re-rendering by pasting this into the DevTools console:
render(createElement("div", null, createElement("h1", null, "World")), document.getElementById("root"));
- The heading should change from Hello to World without a page refresh. The reconciler detected that the div and h1 types matched, so it reused those DOM nodes and only updated the text content.
How can I verify DOM reuse?
In the DevTools Elements panel, right-click the h1 element before re-rendering and select Store as global variable. Then re-render. Run temp1 === document.querySelector('h1') in the console.
It returns true because the reconciler reused the same DOM node. This is the efficiency that makes React fast.
- Test deletion by pasting this into the console (renders a div with no children):
render(createElement("div", null), document.getElementById("root"));
- The heading disappears. The reconciler found an old fiber (the h1) with no matching new element, marked it as DELETION, and the commit phase removed it from the DOM.
Re-render not updating?
- Make sure commitRoot sets currentRoot = wipRoot before nulling wipRoot. Without this line, the next render has no old tree to compare against.
- Check that render sets alternate: currentRoot on the new wipRoot. This is what connects the new render to the previous tree.
Need help? My MiniReact re-render is not updating the DOM. Help me figure out why the reconciler is not detecting changes.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html at the end of this step:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniReact</title>
</head>
<body>
<div id="root"></div>
<script>
window.requestIdleCallback = window.requestIdleCallback || function (handler) {
const startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return { type: "TEXT_ELEMENT", props: { nodeValue: text, children: [] } };
}
function createDom(fiber) {
const dom = fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);
function updateDom(dom, prevProps, nextProps) {
Object.keys(prevProps).filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => { dom.removeEventListener(name.toLowerCase().substring(2), prevProps[name]); });
Object.keys(prevProps).filter(isProperty).filter(isGone(prevProps, nextProps))
.forEach(name => { dom[name] = ""; });
Object.keys(nextProps).filter(isProperty).filter(isNew(prevProps, nextProps))
.forEach(name => { dom[name] = nextProps[name]; });
Object.keys(nextProps).filter(isEvent).filter(isNew(prevProps, nextProps))
.forEach(name => { dom.addEventListener(name.toLowerCase().substring(2), nextProps[name]); });
}
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
let wipFiber = null;
let hookIndex = null;
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) {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) updateFunctionComponent(fiber);
else updateHostComponent(fiber);
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
}
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function updateHostComponent(fiber) {
if (!fiber.dom) fiber.dom = createDom(fiber);
reconcileChildren(fiber, fiber.props.children);
}
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) {
newFiber = { type: oldFiber.type, props: element.props, dom: oldFiber.dom, return: wipFiber, alternate: oldFiber, effectTag: "UPDATE" };
}
if (element && !sameType) {
newFiber = { type: element.type, props: element.props, dom: null, return: wipFiber, alternate: null, effectTag: "PLACEMENT" };
}
if (oldFiber && !sameType) {
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.dom) domParentFiber = domParentFiber.return;
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
return;
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) domParent.removeChild(fiber.dom);
else commitDeletion(fiber.child, domParent);
}
function render(element, container) {
wipRoot = { dom: container, props: { children: [element] }, alternate: currentRoot };
deletions = [];
nextUnitOfWork = wipRoot;
}
render(createElement("div", null, createElement("h1", null, "Hello")), document.getElementById("root"));
</script>
</body>
</html>
Your rendering engine can now create, update, and delete DOM elements efficiently. Next up, you will build the useState hook that gives components their own state and triggers re-renders automatically.
Implement the useState Hook and Counter App
Your rendering engine can create, update, and delete DOM elements through the full render-commit pipeline. The missing piece is interactivity.
Without state, your components are pure functions that always return the same output. In this step, you will implement useState so that components can hold data between renders and trigger re-renders when that data changes.
In this step, get ready to:
- Understand the hook context infrastructure already in your fiber loop.
- Implement the useState hook with state persistence and update queuing.
- Build an interactive Counter app that proves your engine handles real state.
Understand the hook context in updateFunctionComponent
Before writing useState, take a look at the hook infrastructure already sitting inside updateFunctionComponent.
- In index.html, find the updateFunctionComponent function. It should look like this:
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
Why does this matter for hooks?
These three lines before the component function call are the entire reason hooks must be called at the top level in a consistent order:
- wipFiber = fiber sets a global pointer to the fiber currently being rendered. When useState runs, it reads and writes to this fiber's hooks array.
- hookIndex = 0 resets the counter. Each useState call increments hookIndex by one, so the first call always gets slot 0, the second gets slot 1, and so on.
- wipFiber.hooks = [] creates a fresh array that will be filled as each hook executes.
If you put a useState inside a conditional, the index shifts and the wrong state gets returned on re-render. This is the same constraint real React enforces.
Implement useState
The hook needs to do three things: retrieve existing state on re-renders, process any queued updates, and provide a setState function that triggers a new render cycle.
- In index.html, find the commitDeletion function. Add the following useState function below it:
// === HOOKS ===
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
hook.state = typeof action === "function" ? action(hook.state) : action;
});
const setState = action => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
What does this code do?
This function runs every time a component calls useState during a render. It handles both the initial mount and subsequent updates:
- oldHook looks at the previous render's fiber (via wipFiber.alternate) to find existing state. On first render, this is null so initial is used instead.
- The queue holds any actions pushed by setState calls between renders. Each action is applied in order to produce the new state value.
- setState does not update state immediately. It pushes the action into the queue and creates a brand new wipRoot from currentRoot, which kicks off a full re-render cycle through the work loop.
- wipFiber.hooks.push(hook) saves this hook's state into the fiber. hookIndex++ advances the pointer so the next useState call gets a fresh slot.
Why functions in setState?
Notice the check typeof action === "function". This lets callers pass either a direct value or an updater function like c => c + 1. The updater pattern ensures you always work with the latest state, even if multiple updates are queued before the next render.
- Save index.html.
Seeing a syntax error in the console?
- Make sure the function is placed after commitDeletion and before the existing render call at the bottom of the script.
- Check that all curly braces match. The setState arrow function has its own set of braces inside the outer function.
Help me debug a syntax error in my useState implementation.
Build the Counter app component
With useState in place, you can build a real interactive component. The Counter will use two separate useState calls to prove that multiple hooks maintain independent state correctly.
- In index.html, scroll to the bottom of your script and find this line:
render(createElement("div", null, createElement("h1", null, "Hello")), document.getElementById("root"));
- Delete that line and replace it with the following Counter component and render call:
// === APP ===
function Counter() {
const [count, setCount] = useState(0);
const [text, setText] = useState("MiniReact");
return createElement(
"div",
null,
createElement("h1", null, text),
createElement("p", null, "Count: ", count),
createElement(
"button",
{ onClick: () => setCount(c => c + 1) },
"Increment"
),
createElement(
"button",
{ onClick: () => setCount(c => c - 1) },
"Decrement"
),
createElement("input", {
value: text,
onInput: e => setText(e.target.value),
})
);
}
render(createElement(Counter), document.getElementById("root"));
What does this component do?
- useState(0) creates the count state at hook index 0. useState("MiniReact") creates the text state at hook index 1.
- The Increment button passes an updater function c => c + 1 to setCount. This queues the action and triggers a full re-render through the work loop.
- The input uses onInput to update text state on every keystroke. Your updateDom function handles swapping the event listener on each re-render.
- render(createElement(Counter), ...) passes the Counter function as the element type. When the work loop reaches this fiber, updateFunctionComponent calls it to get the virtual element tree.
- Save index.html and refresh your browser.
- You should see a heading that says MiniReact, a paragraph showing Count: 0, two buttons (Increment and Decrement), and a text input.
- Click the **Increment** button a few times. The count should increase.
- Click the **Decrement** button. The count should decrease.
- Type in the text input. The heading should update in real time to match what you type.
Nothing rendering or buttons not working?
- Check that you deleted the old render(createElement("div", ...)) call completely. Having two render calls will conflict.
- Make sure useState is defined above the Counter function. JavaScript needs the function to exist before the component calls it.
- If the count does not change on click, verify that your onClick prop uses a capital C. The isEvent helper checks for the on prefix and strips it to get the DOM event name.
Help me debug my Counter component.
Why does hook call order matter?
Try a thought experiment: if you wrapped the second useState("MiniReact") in an if statement that only runs when count > 0, the first render would only call useState once. On the next render (after incrementing), it would call it twice. The hookIndex would be wrong and the text state would read the count state slot.
This is the exact same limitation real React enforces with its "Rules of Hooks" lint rule. Your implementation makes the reason visible: hooks are just array indices.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html and your Counter app is interactive in the browser.
ⓧ I'd like to double check the full code
Here is the complete index.html file at the end of this step:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniReact</title>
</head>
<body>
<div id="root"></div>
<script>
// Polyfill for requestIdleCallback (MDN-documented pattern)
window.requestIdleCallback = window.requestIdleCallback || function (handler) {
const startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50.0 - (Date.now() - startTime));
},
});
}, 1);
};
// === ELEMENT CREATION ===
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
// === DOM OPERATIONS ===
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = (prev, next) => key => !(key in next);
function updateDom(dom, prevProps, nextProps) {
Object.keys(prevProps)
.filter(isEvent)
.filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach(name => {
dom[name] = "";
});
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
dom[name] = nextProps[name];
});
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
}
// === FIBER WORK LOOP ===
let nextUnitOfWork = null;
let currentRoot = null;
let wipRoot = null;
let deletions = null;
let wipFiber = null;
let hookIndex = null;
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) {
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.return;
}
}
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
// === RECONCILIATION ===
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
return: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
return: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
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++;
}
}
// === COMMIT PHASE ===
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
let domParentFiber = fiber.return;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.return;
}
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
return;
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(fiber.child, domParent);
}
}
// === HOOKS ===
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
hook.state = typeof action === "function" ? action(hook.state) : action;
});
const setState = action => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
// === APP ===
function Counter() {
const [count, setCount] = useState(0);
const [text, setText] = useState("MiniReact");
return createElement(
"div",
null,
createElement("h1", null, text),
createElement("p", null, "Count: ", count),
createElement(
"button",
{ onClick: () => setCount(c => c + 1) },
"Increment"
),
createElement(
"button",
{ onClick: () => setCount(c => c - 1) },
"Decrement"
),
createElement("input", {
value: text,
onInput: e => setText(e.target.value),
})
);
}
render(createElement(Counter), document.getElementById("root"));
</script>
</body>
</html>
Secret mission
Implement useEffect
Your MiniReact engine handles state, but real React also handles side effects. Can you implement useEffect with dependency tracking and cleanup functions, mirroring React's passive effects system?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in a single HTML file, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your file for future reference or delete it entirely.
Resources you used:
- The index.html file containing your MiniReact engine (on your Desktop or wherever you saved it).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your MiniReact engine or refer back to it later.
- Your index.html file stays on your machine. Open it in a browser any time to run your framework.
- There are no background processes, servers, or running services to manage.
Pause - I'll come back to this later
Since this project is a single static file with no running processes, there is nothing to pause. Your file sits on disk until you open it again.
- Close the browser tab with index.html open.
- Close the file in VS Code if you want to free up your editor tabs.
- The file is ready to open again whenever you want to continue.
Delete - I don't want to use this again
Remove the project file completely from your machine.
- Close the file in VS Code and close the browser tab.
- Delete the index.html file by running the appropriate command for your operating system:
macOS
rm ~/Desktop/index.html
Windows
Remove-Item ~\Desktop\index.html
If you saved the file in a different location, adjust the path accordingly.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete React-like rendering engine from scratch in a single HTML file.
You now understand the core architecture that powers React at the level needed to read and contribute to its source code.
You've learned how to:
- Build a Fiber-based rendering engine with an interruptible work loop that processes one fiber at a time and yields to the browser using requestIdleCallback.
- Implement a reconciliation algorithm that diffs virtual element trees by type, marking nodes for placement, update, or deletion, then batch-applies DOM mutations in a separate commit phase.
- Build a useState hook with ordered state slots that demonstrates why hook call order matters in React.
- Secret Mission: Implemented useEffect with dependency tracking and cleanup functions, mirroring React's passive effects system.
Ready to quiz yourself?