
Building a React Clone From Scratch: oveReact
Understanding how React works under the hood by building a custom Virtual DOM, Diffing Engine, and Hooks system in TypeScript.
Building a React Clone From Scratch: oveReact
I recently built oveReact, a custom, lightweight React clone written entirely from scratch in TypeScript. I wanted to deeply understand how React's core concepts work under the hood, stepping away from the "magic" and getting into the nitty-gritty of Virtual DOMs and diffing algorithms.
Unlike simple brute-force clones, oveReact features a true Virtual DOM diffing engine, native HTML DOM property bindings (including Refs), and a robust Hooks system that faithfully mirrors standard React behavior.
The Mental Shift: From Magic to Mechanics
When you first use React, things like useState and useEffect feel magical. But when you build them, you realize it's all just clever closure-based state management and smart DOM manipulation.
Here's a breakdown of what I built:
1. The Virtual DOM Engine
At its core, JSX transpiles down to createElement calls. I built a custom createElement function and a recursive renderNode engine to take these nested objects and convert them into actual DOM nodes.
2. Smart Diffing Algorithm
The most complex part of React is reconciling changes efficiently. My updateElement algorithm compares the old and new Virtual DOM trees, updating only the specific physical DOM nodes that changed. This avoids expensive full-page repaints.
3. Hooks System
Implementing Hooks fundamentally changed how I think about function state:
useState: Uses an array to track state across multiple hook calls via a global pointer index, enabling targeted re-renders via atriggerRerenderloop.useEffect: Tracks dependency arrays ([]) and utilizessetTimeoutto guarantee execution after the DOM paints.useRef: Maintains memory persistence across renders using mutable{ current }objects, intercepting therefprop to attach native DOM nodes.
Dry Run Example
Let's clear up your two big questions first, and then do the complete step-by-step dry run using your actual app.tsx code.
1. How does Counter get executed inside Component()?
In JavaScript, functions can be passed around like regular variables.
In app.tsx, you wrote: renderApp(Counter, root);. You are handing the Counter function itself over to renderApp.
In renderer.ts, the function receives it:
export function renderApp(Component: () => VDomElement, container: HTMLElement) {
// Here, 'Component' is literally just a nickname for 'Counter'
const newVDom = Component(); // This is EXACTLY the same as writing Counter()
}Because Component is holding the Counter function, writing Component() executes the code block inside Counter from top to bottom.
2. Why does hookIndex increase if resetHookIndex resets it to 0?
This is about timing. resetHookIndex() is only called ONCE at the very beginning of the render cycle.
renderAppstarts.resetHookIndex()fires ->hookIndexis now0.Counter()starts executing top-to-bottom.- It hits the 1st
useState-> uses index 0, then incrementshookIndexto1. - It hits the 2nd
useState-> uses index 1, then incrementshookIndexto2. - It hits the 1st
useEffect-> uses index 2, then incrementshookIndexto3.
- It hits the 1st
Counter()finishes rendering.hookIndexis sitting at3.
If a user clicks a button and triggers a re-render, renderApp runs again. The very first thing it does is call resetHookIndex(), snapping it back from 3 to 0, so the process can repeat flawlessly!
The Complete Dry Run (Using your app.tsx)
Here is exactly how execution jumps between your files during the lifecycle of your app.
Phase 1: The Initial Boot (First Render)
app.tsx: The browser reads your file and hitsrenderApp(Counter, root).renderer.ts: Execution jumps torenderApp.- Saves
rootContainer = rootandrootComponent = Counter. - Calls
resetHookIndex(). (Inhooks.ts,hookIndex = 0). - Calls
Component(). (Execution jumps back to theCounterfunction inapp.tsx).
- Saves
app.tsx(Inside Counter):- Line 4:
const [count, setCount] = useState(0);- Jumps to
hooks.ts:currentIndexis0. hooks[0]is empty, so it setshooks[0] = 0.hookIndexincreases to1.- Returns
0and thesetCountfunction.
- Jumps to
- Line 5:
const [text, setText] = useState("Hello");- Jumps to
hooks.ts:currentIndexis1. hooks[1]is empty, so it setshooks[1] = "Hello".hookIndexincreases to2.- Returns
"Hello"and thesetTextfunction.
- Jumps to
- Line 7:
useEffect(...)- Jumps to
hooks.ts:hookIndexis2. - Sets
hooks[2] = [count](saving the dependencies). - Schedules the effect to run.
hookIndexincreases to3.
- Jumps to
- Line 12:
Counterreturns the JSX (which transpiles to aVDomElementobject).
- Line 4:
renderer.ts: We are back!const newVDomnow holds that returned object.- It sees there is no
currentVDomyet, so it callsrenderNode(newVDom)to draw it to the physical screen. - Saves
currentVDom = newVDom. The render is complete.
- It sees there is no
Phase 2: The Button Click!
A user clicks the <button onClick={() => setCount(count + 1)}>Increment</button>.
hooks.ts: ThesetCountfunction runs.- Remember,
setCountwas tied to index0. It updateshooks[0] = 1. - It calls
triggerRerender().
- Remember,
renderer.ts:triggerRerendercallsrenderApp(rootComponent, rootContainer).
Phase 3: The Second Render (Re-render)
renderer.ts:renderAppstarts again!- Calls
resetHookIndex(). (Inhooks.ts,hookIndexresets from3back down to0!). - Calls
Component(). (Execution jumps back toCounter).
- Calls
app.tsx(Inside Counter):- Line 4:
const [count, setCount] = useState(0);- Jumps to
hooks.ts:currentIndexis0. hooks[0]is NOT empty (it's1because of the button click!). It skips initialization.hookIndexincreases to1.- Returns
1and thesetCountfunction.
- Jumps to
- Line 5:
const [text, setText] = useState("Hello");- Jumps to
hooks.ts:currentIndexis1. hooks[1]is NOT empty (it's"Hello"). It skips initialization.hookIndexincreases to2.- Returns
"Hello"andsetText.
- Jumps to
- Line 7:
useEffect(...)- Jumps to
hooks.ts:hookIndexis2. - It compares the old dependency (
hooks[2], which was0) with the new dependency (count, which is1). - Because
0 !== 1, it knows the dependency changed! It schedules the effect to run again and saveshooks[2] = [1]. hookIndexincreases to3.
- Jumps to
- Line 12:
Counterreturns the newly updatedVDomElement(withCount is: 1).
- Line 4:
renderer.ts: We are back.const newVDomholds the updated object.- This time,
currentVDomexists! It callsupdateElement(container, newVDom, currentVDom, 0)to figure out exactly what text changed and magically updates just that one tiny piece of the physical screen.
- This time,
And that is the complete, exact flow of your custom React framework!
Dry Run with useEffect
Let's do a dry run using your exact Counter component from app.tsx!
Here is the setup:
function Counter() {
const [count, setCount] = useState(0); // Uses hookIndex 0
const [text, setText] = useState("Hello"); // Uses hookIndex 1
useEffect(() => { // Uses hookIndex 2
document.title = `Count is ${count}`;
}, [count]);
return <div.../>;
}Behind the scenes:
hooks = []
hookIndex = 0
Phase 1: The First Render
renderApp runs, resets hookIndex = 0, and calls Counter().
useState(0)setshooks[0] = 0.hookIndexbecomes1.useState("Hello")setshooks[1] = "Hello".hookIndexbecomes2.- Execution reaches
useEffect:depspassed into the function is[0]. (Becausecountis currently0).hookIndexis currently2.hasNoDeps = false(Because you passed an array).oldDeps = hooks[2]. Since this is the first render,hooks[2]isundefined.depsChanged = true(BecauseoldDepsis undefined).- Because
depsChangedis true, theifblock executes!- It saves
hooks[2] = [0]. (We have successfully saved the dependency for next time!) - It schedules
document.title = "Count is 0"to run viasetTimeout.
- It saves
hookIndexincreases to3.
State of the hooks array after 1st render: [0, "Hello", [0]]
(A few milliseconds later, the browser finishes drawing the DOM, and setTimeout fires, updating the document title to "Count is 0".)
Phase 2: The User types in the Text Input!
The user types in the input box, triggering setText("Hello World").
hooks[1]updates to"Hello World".triggerRerender()runs.
Phase 3: The Second Render (Text changed, but count did not)
renderApp runs, resets hookIndex = 0, and calls Counter().
useState(count) readshooks[0], returns0.hookIndexbecomes1.useState(text) readshooks[1], returns"Hello World".hookIndexbecomes2.- Execution reaches
useEffect:depspassed into the function is still[0]. (Becausecountis still0).hookIndexis currently2.oldDeps = hooks[2], which we saved last time as[0].depsChangedruns.every()to compare the old array[0]against the new array[0].0 === 0, sodepsChanged = false!- Because
depsChangedis false, theifblock is skipped entirely. The effect does NOT run, andsetTimeoutis ignored. hookIndexincreases to3.
(The effect successfully avoided running because count didn't change!)
Phase 4: The User clicks the Increment Button!
The user clicks the button, triggering setCount(1).
hooks[0]updates to1.triggerRerender()runs.
Phase 5: The Third Render (Count changed!)
renderApp runs, resets hookIndex = 0, and calls Counter().
useState(count) readshooks[0], returns1.hookIndexbecomes1.useState(text) readshooks[1], returns"Hello World".hookIndexbecomes2.- Execution reaches
useEffect:depspassed into the function is now[1]. (Becausecountis now1).hookIndexis currently2.oldDeps = hooks[2], which is currently[0].depsChangedruns.every()to compare the old array[0]against the new array[1].0 !== 1, so.every()fails, meaningdepsChanged = true!- Because
depsChangedis true, theifblock executes!- It updates
hooks[2] = [1]. (Overwriting the old dependency). - It schedules
document.title = "Count is 1"viasetTimeout.
- It updates
hookIndexincreases to3.
Final state of the hooks array: [1, "Hello World", [1]]
(A few milliseconds later, setTimeout fires, updating the document title to "Count is 1".)
Why Build This?
Building a tool from scratch is the ultimate way to understand it. I created a built-in CLI tool (cli.ts) for scaffolding new projects, just like create-react-app. You can run bun cli.ts my-new-app and it instantly spins up a full starter template!
You can check out the source code on GitHub.

