I used React for a long time before I realized something slightly embarrassing:
I couldn’t actually explain how the Virtual DOM worked.
I knew the rules everyone repeats:
"Don't mutate state"
"Add keys to your lists"
"React diffs the virtual DOM"
But if you asked me what actually happens when setState() runs? I'd fumble through a vague explanation about "reconciliation" and hope you didn't ask follow-up questions.
So I did what any student with too much time during semester break would do: I stopped reading blog posts and built my own Virtual DOM from scratch.
That experiment became Weave — a complete VDOM reconciler written in TypeScript with keyed reconciliation, lifecycle hooks, immutable VNodes, and a live metrics dashboard that visualizes its own internal operations.
This isn't a tutorial. It's what I learned by actually building the thing.
The Problem: Why Virtual DOM Even Exists
Let me show you the most naive way to update the DOM:
function updateCounter(count) {
document.body.innerHTML = `
<div>
<h1>Counter: ${count}</h1>
<button onclick="updateCounter(${count + 1})">+1</button>
</div>
`;
}
This works. For exactly one click.
Then you notice:
- The entire DOM tree gets destroyed
- Every element gets recreated from scratch
- All event listeners re-register
- You lose input focus, scroll position, everything
For a toy counter? Maybe acceptable.
For a real app with hundreds of elements? Completely unusable.
The Virtual DOM solves this by adding a middle layer:
Describe what you want as plain JavaScript objects (VNodes)
Compare the old description vs the new one
Generate minimal change instructions (patches)
Apply only those specific changes to the real DOM
Instead of rebuilding everything, you reuse what's already there and update only what changed.
The real challenge isn't applying changes efficiently. It's figuring out what changed in the first place.
Architecture: The One Rule I Refused to Break
Before I wrote any code, I made one non-negotiable decision:
The diffing algorithm cannot know anything about the DOM.
Why? Because comparing two tree structures is pure logic. It shouldn't matter if you're rendering to:
Browser DOM
Canvas
Terminal UI
React Native
Or something completely different
That led me to this layered architecture:
┌───────────────────────────────┐
│ USER CODE │
│ h('div', props, children) │
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ CORE │
│ (Platform-Agnostic Logic) │
│ │
│ • VNode creation (immutable)│
│ • __id identity assignment │
│ • diff(old, new) │
└───────────────────────────────┘
│
▼
┌─────────────────┐
│ Patch[] │
│ (Declarative) │
└─────────────────┘
│
▼
┌───────────────────────────────┐
│ RENDERER │
│ │
│ • Applies patches │
│ • Preserves DOM identity │
│ • Runs lifecycle hooks │
│ • Tracks metrics │
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ DOM HOST │
│ (Platform Layer) │
│ │
│ createElement() │
│ setProp() │
│ insert() / remove() │
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ REAL DOM │
│ <div><h1>Hello</h1></div> │
└───────────────────────────────┘
Let me break down what each layer does:
Core Layer (Platform-Agnostic)
This is where the actual diffing happens. It knows nothing about browsers, DOM nodes, or document.createElement().
// From: src/core/types.ts
export interface VNode {
readonly type: VNodeType; // 'div', 'span', etc.
readonly props: VNodeProps; // { class: 'box', onClick: ... }
readonly children: VNodeChildren; // 'text' or [VNode, VNode]
readonly key: VNodeKey; // For list reconciliation
}
A VNode is just data. No methods, no hidden behavior.
The diff() function takes two trees and returns declarative patches:
// From: src/core/diff.ts
export function diff(
oldVNode: VNode | null,
newVNode: VNode | null
): Patch[] {
// Returns instructions like:
// [
// { type: 'UPDATE_TEXT', vnode: ..., value: 'New text' },
// { type: 'SET_PROP', vnode: ..., key: 'class', value: 'active' }
// ]
}
Renderer Layer (Platform Bridge)
The renderer takes those patches and applies them. It maintains a map of VNode IDs to real DOM nodes:
// From: src/renderer/createRenderer.ts
const nodeMap = new Map<number, Node>();
function commit(patches: Patch[]): void {
for (const patch of patches) {
switch (patch.type) {
case 'UPDATE_TEXT': {
const node = nodeMap.get(patch.vnode.__id);
node.textContent = patch.value;
break;
}
// ... handle other patch types
}
}
}
Platform Layer (DOM-Specific)
This is the only part that actually touches the browser DOM:
// From: src/platforms/dom/host.ts
export const domHost: HostConfig<DomNode> = {
createElement(type: string): HTMLElement {
return document.createElement(type);
},
setProp(node: DomNode, key: string, value: unknown): void {
if (key.startsWith('on') && typeof value === 'function') {
const eventName = key.slice(2).toLowerCase();
node.addEventListener(eventName, value as EventListener);
} else {
node.setAttribute(key, String(value));
}
},
insert(parent: DomNode, child: DomNode, index: number): void {
const refNode = parent.childNodes[index] ?? null;
parent.insertBefore(child, refNode);
}
// ... more methods
};
Why this matters: If I want to render to Canvas tomorrow, I only swap out the platform layer. The core diffing algorithm stays exactly the same.
I even enforced this separation with ESLint:
// From: eslint.config.cjs
'import/no-restricted-paths': [
'error',
{
zones: [
{
target: './src/core',
from: ['./src/renderer', './src/platforms'],
message: 'core must remain platform-agnostic'
}
]
}
]
If I accidentally try to import DOM code into the core? Build fails. The architecture boundary is enforced at compile time.
Immutability: The Decision That Prevented So Many Bugs
Every VNode in Weave is completely frozen:
// From: src/core/vnode.ts
export function createVNode(
type: VNodeType,
props: VNodeProps,
children: VNodeChildren,
key: VNodeKey = null
): VNode {
const vnode: VNode = { type, props, children, key };
// Freeze everything
Object.freeze(vnode);
if (props) Object.freeze(props);
if (Array.isArray(children)) Object.freeze(children);
return vnode;
}
At first, this felt unnecessary. "Who would mutate a VNode anyway?"
Turns out, accidentally mutating VNodes is extremely easy when you're debugging and testing different approaches. And when it happens, the bugs are subtle and confusing.
Freezing gives you:
1. Guaranteed immutability
const vnode = h('div', null, 'Hello');
vnode.children = 'World'; // ❌ TypeError in strict mode
2. Fast reference equality checks
if (oldVNode === newVNode) {
// Guaranteed to be identical - skip diffing entirely
return [];
}
3. Easier debugging
- VNodes can't change after creation
- No "who modified this?" mysteries
- Data flow is predictable
The trade-off is slightly higher memory usage (more objects created), but it's absolutely worth it.
The Internal __id System: Solving DOM Node Identity
Here's a problem I didn't anticipate early on:
How do you know which DOM node to reuse when the VNode tree changes?
Consider this update:
// Before
const before = h('div', null, [
h('p', { key: 'a' }, 'Hello'),
h('p', { key: 'b' }, 'World')
]);
// After
const after = h('div', null, [
h('p', { key: 'b' }, 'World'), // moved up
h('p', { key: 'a' }, 'Goodbye') // moved down, text changed
]);
The keys tell us semantic identity ('a' is still 'a', 'b' is still 'b').
But when the renderer processes this, how does it know which actual
element in the DOM corresponds to which VNode?
Solution: Internal stable IDs
// From: src/core/vnode.ts
let vnodeId = 0;
export function createVNode(...): VNode {
const vnode: VNode = { type, props, children, key };
// Attach non-enumerable __id
Object.defineProperty(vnode, '__id', {
value: vnodeId++,
enumerable: false, // Won't show in console.log or JSON.stringify
writable: false,
configurable: false
});
Object.freeze(vnode);
return vnode;
}
Now the renderer maintains a simple map:
// From: src/renderer/createRenderer.ts
const nodeMap = new Map<number, Node>();
function createNode(vnode: VNode): Node {
const node = host.createElement(vnode.type);
nodeMap.set(vnode.__id, node); // Store the mapping
return node;
}
When a MOVE patch happens, we can instantly look up the real DOM node and reposition it:
case 'MOVE': {
const parentNode = nodeMap.get(patch.parent.__id);
const childNode = nodeMap.get(patch.vnode.__id);
// Reuse existing DOM node - don't recreate
host.remove(childNode);
host.insert(parentNode, childNode, patch.to);
break;
}
This is when keys finally clicked for me. They're not about micro-optimizations. They're about preserving identity so the renderer knows what to reuse.
Patch-Based Updates: The Core Insight
The diffing algorithm never touches the DOM. It just returns declarative patches:
// From: src/core/patch-types.ts
export type Patch =
| ReplacePatch // Replace entire subtree
| UpdateTextPatch // Update text content in place
| InsertPatch // Add new child at index
| RemovePatch // Remove child
| SetPropPatch // Set/update property
| RemovePropPatch // Remove property
| MovePatch // Reorder child (keyed lists)
| UpdatePatch; // Trigger lifecycle hooks
For example, changing text from "Hello" to "World" produces:
[
{
type: 'UPDATE_TEXT',
vnode: previousVNode,
value: 'World'
},
{
type: 'UPDATE',
oldVNode: previousVNode,
newVNode: currentVNode
}
]
This separation gives you:
1. Pure testability
const patches = diff(oldTree, newTree);
expect(patches).toEqual([
{ type: 'UPDATE_TEXT', value: 'World' }
]);
// No DOM needed!
2. Batching opportunities
- Collect patches from multiple updates
- Apply them all at once
- Minimize layout thrashing
3. Platform flexibility
- Same patches work for DOM, Canvas, Terminal
- Just swap the renderer
Keyed vs Non-Keyed Reconciliation: The Algorithm That Made It Click
This is where it got really interesting.
Non-Keyed (Simple Index-Based)
Without keys, diffing is straightforward but inefficient:
// From: src/core/diff.ts (non-keyed path)
for (let i = 0; i < max; i++) {
const oldChild = oldChildren[i] ?? null;
const newChild = newChildren[i] ?? null;
if (oldChild === null && newChild !== null) {
patches.push({ type: 'INSERT', vnode: newChild, index: i });
}
else if (oldChild !== null && newChild === null) {
patches.push({ type: 'REMOVE', vnode: oldChild });
}
else if (oldChild && newChild) {
patches.push(...diffNonNull(oldChild, newChild));
}
}
Problem: Reordering causes unnecessary updates:
// Before: ['A', 'B', 'C']
// After: ['C', 'A', 'B']
// Without keys, index-based diff sees:
// Position 0: 'A' → 'C' (UPDATE_TEXT)
// Position 1: 'B' → 'A' (UPDATE_TEXT)
// Position 2: 'C' → 'B' (UPDATE_TEXT)
// Result: All 3 nodes get their text updated!
Keyed (Identity-Preserving)
With keys, we build a map and track which nodes moved:
// From: src/core/diff.ts (keyed path)
if (hasKeys) {
// Build lookup map: key → VNode
const oldKeyMap = new Map<string | number, VNode>();
oldChildren.forEach(child => {
if (child.key != null) {
oldKeyMap.set(child.key, child);
}
});
const usedOld = new Set<VNode>();
newChildren.forEach((newChild, newIndex) => {
const key = newChild.key;
if (key != null && oldKeyMap.has(key)) {
const oldChild = oldKeyMap.get(key)!;
usedOld.add(oldChild);
// Found matching key - reuse the node
patches.push(...diffNonNull(oldChild, newChild));
// Check if it moved
const oldIndex = oldIndexMap.get(oldChild)!;
if (oldIndex !== newIndex) {
patches.push({
type: 'MOVE',
vnode: oldChild,
from: oldIndex,
to: newIndex
});
}
} else {
// New node - insert it
patches.push({
type: 'INSERT',
vnode: newChild,
index: newIndex
});
}
});
// Remove unused old nodes
oldChildren.forEach(oldChild => {
if (!usedOld.has(oldChild)) {
patches.push({ type: 'REMOVE', vnode: oldChild });
}
});
}
With keys:
// Before: [A(key='a'), B(key='b'), C(key='c')]
// After: [C(key='c'), A(key='a'), B(key='b')]
// Keyed diff produces:
// - MOVE C from index 2 → 0
// - MOVE A from index 0 → 1
// - MOVE B from index 1 → 2
// Result: All nodes reused, just repositioned. Zero text updates!
Performance impact:
- Without keys: O(n) DOM updates (text changes)
- With keys: O(n) MOVE operations, zero DOM recreation
This is why React complains about missing keys. It's not nitpicking — it's the difference between recreating elements and simply moving them.
The Hard Parts (Where I Actually Broke Things)
Challenge 1: DOM Nodes Can't "Move"
This bug took me embarrassingly long to figure out.
DOM nodes don't have a "move" operation. You can only remove and re-insert:
// From: src/renderer/createRenderer.ts
case 'MOVE': {
const parentNode = nodeMap.get(patch.parent.__id);
const childNode = nodeMap.get(patch.vnode.__id);
// ❌ WRONG - trying to be clever
// host.insert(parentNode, childNode, patch.to);
// ✅ CORRECT - explicit remove first
host.remove(childNode);
host.insert(parentNode, childNode, patch.to);
break;
}
Why? Because insertBefore() has a side effect: if the node already exists in the parent, it gets moved. But if you're trying to swap adjacent nodes, this breaks:
// Trying to swap [A, B] → [B, A]
// Naive approach:
// 1. Insert B at index 0 → [B, A, B] (B duplicates!)
// 2. Insert A at index 1 → [B, A, B, A] (Both duplicate!)
The fix: Always remove first. It's more explicit and actually works correctly.
Challenge 2: Async Removal Hooks
I wanted to support exit animations, so I added lifecycle hooks:
// From: src/core/types.ts
export interface VNodeHooks<Node = unknown> {
create?: (vnode: VNode, node: Node) => void;
update?: (oldVNode: VNode, newVNode: VNode, node: Node) => void;
remove?: (vnode: VNode, node: Node, done: () => void) => void;
}
The remove hook gets a done callback that controls when removal actually happens:
// From: src/renderer/createRenderer.ts
case 'REMOVE': {
const node = nodeMap.get(patch.vnode.__id);
const removeHook = patch.vnode.props?.hooks?.remove;
const finalizeRemoval = () => {
host.remove(node);
metrics.nodes.removed++;
nodeMap.delete(patch.vnode.__id);
};
if (removeHook) {
// Let the hook control timing
removeHook(patch.vnode, node, finalizeRemoval);
} else {
// No hook - remove immediately
finalizeRemoval();
}
break;
}
Usage:
h('div', {
hooks: {
remove(vnode, node, done) {
// Fade out over 300ms
node.style.transition = 'opacity 300ms';
node.style.opacity = '0';
setTimeout(done, 300); // Call done() when animation finishes
}
}
}, 'Goodbye!')
The element stays in the DOM during the animation, then gets removed when done() is called.
Challenge 3: Keeping the Identity Map in Sync
This was a subtle bug that caused mysterious issues.
When you update a VNode, it gets a new internal ID:
const vnode1 = h('div', null, 'Hello'); // __id: 0
const vnode2 = h('div', null, 'World'); // __id: 1 (different ID!)
But the actual DOM node stays the same
.If the renderer doesn't update its nodeMap, it keeps pointing to the old ID and future patches fail.
The fix: Always emit an UPDATE patch:
// From: src/core/diff.ts
function diffNonNull(prev: VNode, next: VNode): Patch[] {
const patches: Patch[] = [];
// ... all the actual diffing logic ...
// ✅ CRITICAL: Always emit UPDATE to sync IDs
patches.push({
type: 'UPDATE',
oldVNode: prev,
newVNode: next
});
return patches;
}
Then the renderer updates the mapping:
// From: src/renderer/createRenderer.ts
case 'UPDATE': {
const oldV = patch.oldVNode as VNodeWithId;
const newV = patch.newVNode as VNodeWithId;
const node = nodeMap.get(oldV.__id);
if (node) {
// Update the mapping
nodeMap.delete(oldV.__id);
nodeMap.set(newV.__id, node);
// Run lifecycle hook if present
newV.props?.hooks?.update?.(oldV, newV, node);
}
break;
}
Now the map stays in sync across all updates.
The Dashboard: Proof It Actually Works
At some point I got tired of guessing if optimizations were working. So I built a live metrics dashboard using Weave itself
These are the high-level stats:
-
Updates (23): Total number of
root.update()calls - Avg Time (0.65ms): Average duration of each update cycle (diff + patch application)
- Active Nodes (1017): Current number of DOM nodes being managed
- Total Patches (3005): Cumulative patch operations applied since initialization
The fact that average update time is sub-millisecond shows the VDOM overhead is negligible.
Patch Timeline (Left Middle)
This is my favorite part. It shows recent patch operations in reverse chronological order:
05:30:27.519 UPDATE node
05:30:27.519 UPDATE node
05:30:27.519 UPDATE node
05:30:27.519 UPDATE_TEXT Text → "20"
Each row shows:
- Timestamp: When the patch was applied (millisecond precision)
- Patch Type: UPDATE, UPDATE_TEXT, INSERT, etc.
- Description: What changed (e.g., text content, VNode ID)
When you click the +1 button, you see new UPDATE patches appear immediately. It's the VDOM watching itself work.
Patch Operations (Right Middle)
This heatmap shows the distribution of patch types:
- UPDATE (2034): Most common - fired on every node that's reused
- INSERT (320): New nodes added
- REMOVE (282): Nodes removed
- UPDATE_TEXT (223): Text content changes
- SET_PROP (145): Property updates
- REPLACE (1): Full subtree replacement (rare)
- MOVE (0): No list reorderings yet
- REMOVE_PROP (0): No properties removed yet
The horizontal bars give you a visual sense of what operations dominate. In this session, UPDATE patches far outnumber everything else — which is exactly what you want. It means nodes are being reused, not recreated.
Update Duration Trend (Bottom Left)
This bar chart shows performance over the last 20 updates. Each bar represents one update() call.
You can see the durations are very consistent (all around 0.6-0.8ms) with a few spikes. Those spikes happen when:
- Large batch updates occur
- Many nodes are inserted/removed at once
- Complex prop updates happen
For a production system, you'd watch this chart for regressions. If a change causes update times to spike, you know something broke.
Interactive Demo (Bottom Right)
The counter (currently at "21") with +1/-1 buttons.
Here's the meta part: This counter is built with Weave. When you click +1:
- The counter VNode updates: h('div', null, String(21)) → h('div', null, String(22))
- The diff algorithm generates patches
- The renderer applies them
- All of this gets recorded in the metrics
- The dashboard updates itself (also using Weave) to show the new patches
It's Weave observing itself in real-time.
The entire dashboard is ~300 lines of code using the same h() function and update mechanism:
// From: demo/dashboard/dashboard.ts
function Dashboard(metrics: RendererMetrics, counter: number): VNode {
return h('div', { class: 'dashboard-container' }, [
// Performance metrics
h('div', { class: 'metrics-grid' }, [
MetricCard('Updates', String(metrics.updates + 1), 'total'),
MetricCard('Avg Time', metrics.avgUpdateDurationMs.toFixed(2), 'ms'),
MetricCard('Active Nodes', String(metrics.nodes.active), 'nodes'),
MetricCard('Total Patches', String(metrics.patches.total), 'ops')
]),
// Patch timeline
PatchTimeline(metrics.patchHistory),
// Patch heatmap
PatchHeatmap(metrics),
// Update duration chart
LiveChart(metrics.history.durations),
// Interactive demo
InteractiveDemo(counter, onIncrement, onDecrement)
]);
}
function renderDashboard(): void {
const vnode = Dashboard(dashboardRoot.metrics, demoCounter);
dashboardRoot.update(vnode);
}
Watching this run made everything "click" in a way reading React source code never did.
What I Actually Learned
Immutability Isn't Academic
Freezing objects felt wasteful when I first did it. "Why prevent mutation that won't happen?"
Turns out, accidental mutations do happen during development. And when they do, the bugs are incredibly subtle.
Frozen VNodes eliminated an entire class of issues I didn't even know existed.The DOM Is Always the Bottleneck
I spent time optimizing the diff algorithm — reducing allocations, caching results, etc.
Then I profiled it.
VDOM overhead: 0.01-0.05ms
Actual DOM manipulation: 0.5-2ms
The abstraction isn't the slow part. It never was.Separation of Concerns Is Hard But Worth It
Keeping the core platform-agnostic required constant discipline:
Resisting shortcuts ("just import document here")
Designing clean interfaces
Fighting the urge to optimize for one specific platform
But now:
The core is fully unit-testable (no jsdom required)
The renderer is swappable
Adding Canvas/Terminal support is just a new host layer
The architecture actually scales.
- Keys Aren't About Performance Micro-Optimizations Before building this, I thought keys were about making React "go faster." That's not it. Keys are about preserving identity so the reconciler knows which nodes to reuse vs recreate. Without them, reordering a list destroys and recreates everything. With them, nodes just move. It's not a speedup. It's a completely different algorithm.
What I'd Do Differently
- Add Fragment Support from the Start
Right now, every VNode needs a single root:
// ❌ Not supported
return [
h('li', null, 'Item 1'),
h('li', null, 'Item 2')
];
// ✅ Must wrap
return h('ul', null, [
h('li', null, 'Item 1'),
h('li', null, 'Item 2')
]);
Fragments would eliminate unnecessary wrapper elements.
- Build a Component Abstraction Earlier
The current API is pure VNodes. No state, no lifecycle beyond hooks.
Adding lightweight components would make Weave actually practical:
function Counter({ initialValue }) {
const [count, setCount] = useState(initialValue);
return h('div', null, [
h('h1', null, String(count)),
h('button', { onclick: () => setCount(count + 1) }, '+1')
]);
}
This is the natural next step.
- Profile Much Earlier
I didn't add metrics until after building most of the core.
Some decisions (like always freezing children arrays) might have been different if I'd measured from the start.
Next time: metrics first, optimizations second.
What's Next
- Short-term (weeks):
- Fragment support - Allow returning arrays without wrappers
- Component abstraction - Lightweight state management
- Performance benchmarks - Compare against React/Preact
- Medium-term (months):
- Async rendering - Interruptible updates, priority-based scheduling
- Additional platforms - Canvas renderer, Terminal UI
- Developer tools - Browser extension, component inspector
- Long-term (ambitious):
- JSX support - Better DX than raw h() calls
- Server-side rendering - Render to HTML string + client hydration
- Small ecosystem - Router, forms, state management
Try It Yourself
Github :
Symphony007
/
Weave---VirtualDOM
A renderer-agnostic Virtual DOM engine built from scratch
Weave VDOM
A production‑grade Virtual DOM reconciler built from scratch with TypeScript Weave implements a platform‑agnostic diffing algorithm with keyed reconciliation, lifecycle hooks, and strict immutability guarantees.
✨ Key Features
Core Architecture
- Platform‑agnostic diffing core with zero DOM dependencies
- Keyed reconciliation for efficient list updates
- Fallback non‑keyed diffing for simple lists
-
Immutable VNodes using
Object.freeze() - Type‑safe patch operations with full TypeScript inference
Advanced Capabilities
- Lifecycle hooks:
create,update,remove - DOM identity preservation (nodes reused when possible)
- WeakMap‑based event handling
- Built‑in performance metrics
- Zero runtime dependencies
🔧 Patch Operations
Weave generates minimal, declarative patch sets:
- REPLACE – Replace entire subtree
- UPDATE_TEXT – Update text content in place
- INSERT – Add new child at index
- REMOVE – Remove child (supports async cleanup)
- MOVE – Reorder children (keyed lists)
- SET_PROP / REMOVE_PROP – Minimal property updates
- UPDATE – Trigger lifecycle hooks
🏗️ Architecture
src/
├── core/ # Platform‑agnostic VDOM core
│…

Top comments (0)