A mini React-like Virtual DOM engine built entirely from scratch in TypeScript. No React, Vue, Preact, or any rendering library — just vanilla TypeScript and browser APIs.
src/
├── core/ # Engine internals
│ ├── h.ts # Hyperscript factory (VNode creation)
│ ├── vnode.ts # VNode data structure
│ ├── mount.ts # Initial DOM mounting
│ ├── patch.ts # DOM patching entry
│ ├── diff.ts # Reconciliation algorithm (keyed + unkeyed)
│ ├── reconciler.ts # Core patch/mount/unmount orchestration
│ ├── renderer.ts # render() and renderToString() entry points
│ ├── scheduler.ts # Batched async updates (microtask queue)
│ └── component.ts # Stateful class components + lifecycle
├── hooks/ # Hook system
│ ├── hookState.ts # Per-component hook cursor + state storage
│ ├── useEffect.ts # Effect scheduling with cleanup
│ └── useState.ts # State hook
├── dom/ # DOM operations
│ ├── createElement.ts # createElement / createElementNS
│ ├── props.ts # Property/attribute diffing
│ └── events.ts # Centralized event delegation
├── shared/ # Types, utils, constants
├── demo/ # Demo applications
├── router/ # Tiny History API router (stretch goal)
└── tests/ # Vitest + jsdom test suite
Every UI element is a VNode — a plain object describing what to render:
type VNode = {
type: string | Component | symbol;
props: Record<string, unknown>;
children: VNode[];
key?: string | number;
el?: Node; // real DOM reference after mount
component?: ComponentInstance;
};The h() hyperscript function creates VNodes. JSX is configured to compile to h() calls.
mount(vnode, container) walks the VNode tree and creates real DOM nodes:
- HTML elements via
document.createElement - SVG elements via
document.createElementNS - Text nodes via
document.createTextNode - Fragments use a comment anchor (no wrapper element)
patch(oldVNode, newVNode, container) is the core algorithm:
- Same type → patch props/children in place
- Different type → unmount old, mount new
- Keyed children → key map lookup + reverse-order DOM insertion
- Unkeyed children → index-based diff
Keyed list reconciliation uses a three-pass algorithm:
- Pass 1: Match by key, patch existing nodes
- Pass 2: Mount new nodes
- Pass 3: Insert from end-to-start to establish correct DOM order
Multiple setState() calls in the same tick are batched into one rerender via queueMicrotask:
setState({ count: 1 });
setState({ count: 2 });
setState({ count: 3 });
// → ONE reconciliation cycle, final count = 3Instead of attaching listeners on every element, one listener per event type is attached at the root container. Events bubble up and are dispatched to the correct handler via a WeakMap<Node, handlers>.
A minimal hooks system with per-component hook index tracking:
useState(initial)— component state with setteruseEffect(fn, deps)— side effects with cleanup and dependency tracking
Class components support:
onMount()— after first renderonUpdate(prevProps, prevState)— after each updateonDestroy()— before unmount (with effect cleanup)
npm install
npm run dev # Start dev server at http://localhost:5173
npm test # Run test suite (39 tests)
npm run build # Production buildimport { h, render, Component, useState, useEffect } from './index';
// Class component
class Counter extends Component {
state = { count: 0 };
render() {
return h('button', {
onClick: () => this.setState({ count: this.state.count + 1 })
}, [`Count: ${this.state.count}`]);
}
}
// Functional component with hooks
function Greeting(props: { name: string }) {
const [greet, setGreet] = useState('Hello');
useEffect(() => {
console.log('mounted');
return () => console.log('cleanup');
}, []);
return h('h1', {}, [`${greet}, ${props.name}`]);
}
// Render
render(h(Counter, {}), document.getElementById('app')!);Configured via tsconfig.json:
{
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}This allows writing <App /> which compiles to h(App, {}).
The demo includes three tabs:
- Todo App — dynamic keyed lists, filters, input handling
- Counters — class + functional components, batched updates
- SVG Demo — SVG rendering via
createElementNS
import { renderToString, h } from './index';
const html = renderToString(h('div', { class: 'app' }, ['Hello']));
// → '<div class="app">Hello</div>'A minimal client-side router using the History API:
import { Router, Link } from './router/router';
const router = h(Router, {
routes: [
{ path: '/', component: () => h(Home, {}) },
{ path: '/about', component: () => h(About, {}) },
]
});39 tests covering:
- VNode creation and normalization
- Mounting and DOM creation
- Text and prop patching
- Keyed list reconciliation (reorder, insert, delete)
- Batched setState
- useEffect lifecycle and cleanup
- Class component lifecycle hooks
- SVG namespace rendering
- SSR serialization
| Operation | Complexity |
|---|---|
| Unkeyed diff | O(n) |
| Keyed diff | O(n) with key map |
| DOM moves | Minimized via end-to-start insertion |
| Event handling | O(1) per event type at root |
| Batched updates | O(1) scheduler flush per tick |
MIT