DEV Community

Deepmalya Mallick
Deepmalya Mallick

Posted on

Building a Virtual DOM from Scratch: What I Learned Reverse-Engineering React

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

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>   │
└───────────────────────────────┘

Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

2. Fast reference equality checks

if (oldVNode === newVNode) {
  // Guaranteed to be identical - skip diffing entirely
  return [];
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

For example, changing text from "Hello" to "World" produces:

[
  {
    type: 'UPDATE_TEXT',
    vnode: previousVNode,
    value: 'World'
  },
  {
    type: 'UPDATE',
    oldVNode: previousVNode,
    newVNode: currentVNode
  }
]
Enter fullscreen mode Exit fullscreen mode

This separation gives you:

1. Pure testability

const patches = diff(oldTree, newTree);
expect(patches).toEqual([
  { type: 'UPDATE_TEXT', value: 'World' }
]);
// No DOM needed!
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

  1. 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.

  2. 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.

  3. 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.

  1. 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

  1. 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.

  1. 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.

  1. 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

  1. Short-term (weeks):
  • Fragment support - Allow returning arrays without wrappers
  • Component abstraction - Lightweight state management
  • Performance benchmarks - Compare against React/Preact
  1. Medium-term (months):
  • Async rendering - Interruptible updates, priority-based scheduling
  • Additional platforms - Canvas renderer, Terminal UI
  • Developer tools - Browser extension, component inspector
  1. 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 :

GitHub logo 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)