Virtual DOM vs Joins

Virtual DOM Diffing Compared to Direct DOM Joins

Both diff, but different things at different times:

Two ways to diff (React 19.3 7,897 reconciliation and d3-selection 3.0.0)
React reconciliation D3 18,089 data join
Compares new element tree vs previous tree new data vs existing DOM nodes
Runs on every render, automatically only when you call data() / join()
Identity key prop among siblings key function, per selection
Updates DOM commits computed changes itself you set attributes on the result

React rebuilds and compares a description of the subtree on every render; D3 pairs data with elements once per join() (one map lookup per datum) and changes only the attributes you set, which suits zooming and brushing. Mixing both in one subtree fights, since each assumes it knows the DOM.

Counting DOM mutations: a keyed D3 join touches only changed attributes; rebuilding the markup replaces every nodeHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; max-width: 600px; }
  .row { display: flex; gap: 8px; }
  .row > div { flex: 1; background: #fff; border: 1px solid #e0d8c8; padding: 4px; min-width: 0; }
  h4 { margin: 0 0 2px; font-size: 12px; }
  svg { width: 100%; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p><button id="step">Next month</button> <label><input type="checkbox" id="auto" checked> play</label> <span id="month"></span></p>
<div class="row">
  <div><h4>d3 join: update attributes in place</h4><svg id="join" viewBox="0 0 280 150" font-size="10"></svg></div>
  <div><h4>rebuild markup every update</h4><svg id="rebuild" viewBox="0 0 280 150" font-size="10"></svg></div>
</div>
<svg id="tally" viewBox="0 0 600 70" font-size="11"></svg>
<script>
  // BookNest sample data, invented for these examples (not real sales)
  const SALES = (() => {
    const books = [
      { id: 1, key: 'harbor', title: 'The Quiet Harbor', author: 'Elena Marsh', genre: 'Fiction', color: '#1f5f8b', price: 18.99, rating: 4.6 },
      { id: 2, key: 'deepweb', title: 'Patterns of the Deep Web', author: 'Ravi Menon', genre: 'Technology', color: '#5b3f99', price: 39.5, rating: 4.3 },
      { id: 3, key: 'saffron', title: 'Salt and Saffron', author: 'Leila Haddad', genre: 'Cooking', color: '#e09a10', price: 24, rating: 4.8 },
      { id: 4, key: 'summit', title: 'Small Steps to Big Summits', author: 'Tom Okafor', genre: 'Self-Help', color: '#3f7d3a', price: 14.99, rating: 4.1 },
      { id: 5, key: 'clock', title: "The Clockmaker's Paradox", author: 'Iris Vale', genre: 'Science Fiction', color: '#b5452f', price: 16.2, rating: 4.5 },
      { id: 6, key: 'glass', title: 'Gardens in Glass', author: 'June Park', genre: 'Gardening', color: '#2a9d8f', price: 22.5, rating: 4.4 },
    ];
    const units = {        // units sold per month, January 2025 to August 2026
      1: [40, 47, 46, 47, 57, 51, 46, 54, 60, 60, 71, 111, 52, 42, 49, 56, 59, 48, 53, 51],
      2: [40, 37, 35, 41, 48, 43, 40, 42, 50, 53, 53, 84, 42, 35, 38, 41, 45, 41, 46, 46],
      3: [46, 45, 41, 46, 53, 49, 46, 45, 60, 51, 63, 100, 48, 48, 49, 48, 53, 49, 48, 52],
      4: [100, 80, 72, 70, 66, 66],                    // released March 2026
      5: [48, 47, 54, 52, 65, 55, 57, 58, 64, 66, 75, 124, 58, 49, 59, 56, 63, 57, 54, 57],
      6: [31, 30, 28, 35, 31, 32, 29, 30, 41, 36, 45, 64, 31, 30, 30, 38, 40, 38, 36, 38],
    };
    const months = d3.utcMonths(new Date('2025-01-01'), new Date('2026-09-01')).map(d3.utcFormat('%Y-%m'));
    const sales = books.flatMap(b => units[b.id].map((n, i) =>
      ({ month: months[i + months.length - units[b.id].length], book: b.id, units: n })));
    const ratings = [[4, 8, 20, 87, 293], [6, 10, 22, 95, 147], [2, 3, 8, 45, 342], [2, 3, 8, 24, 28],
      [4, 8, 20, 90, 228], [4, 7, 20, 82, 147]].map((counts, i) => ({ book: i + 1, counts }));  // 1 to 5 stars
    const regions = [{ id: '826', name: 'United Kingdom', units: 1480 }, { id: '276', name: 'Germany', units: 1120 },
      { id: '250', name: 'France', units: 760 }, { id: '372', name: 'Ireland', units: 540 },
      { id: '528', name: 'Netherlands', units: 410 }, { id: '724', name: 'Spain', units: 320 }];
    const links = [[5, 1, 38], [1, 3, 24], [2, 5, 18], [3, 6, 21], [4, 1, 12], [2, 6, 9], [4, 3, 15], [5, 6, 14]]
      .map(([source, target, value]) => ({ source, target, value }));   // customers who bought both
    return { books, sales, ratings, regions, links };
  })();

  const months = [...new Set(SALES.sales.map(s => s.month))];
  const unitsIn = m => SALES.books.map(b => ({ ...b, units: SALES.sales.find(s => s.book === b.id && s.month === m)?.units ?? 0 }));
  const y = d3.scaleBand(SALES.books.map(b => b.id), [0, 150]).padding(0.15), x = d3.scaleLinear([0, 130], [60, 270]);

  // Count DOM work: every element created or destroyed (with its descendants), every attribute set
  const size = n => n.nodeType === 1 ? 1 + n.querySelectorAll('*').length : 0;
  const counts = { join: 0, rebuild: 0 };
  for (const id of ['join', 'rebuild']) {
    new MutationObserver(list => { counts[id] += list.reduce((n, m) => n + (m.type === 'childList' ?
      d3.sum(m.addedNodes, size) + d3.sum(m.removedNodes, size) : 1), 0); })
      .observe(document.getElementById(id), { subtree: true, childList: true, attributes: true, characterData: true });
  }

  function joinDraw(data) {                              // pairs data with existing elements by key
    d3.select('#join').selectAll('g').data(data, d => d.id).join(enter => {
      const g = enter.append('g').attr('transform', d => `translate(0,${y(d.id)})`);
      g.append('text').attr('x', 55).attr('y', y.bandwidth() / 2).attr('dy', '0.35em').attr('text-anchor', 'end').text(d => d.key);
      g.append('rect').attr('x', 60).attr('height', y.bandwidth()).attr('fill', d => d.color);
      return g;
    }).select('rect').attr('width', d => x(d.units) - 60);
  }
  function rebuildDraw(data) {                           // throws the old nodes away every time
    document.getElementById('rebuild').innerHTML = data.map(d => `<g transform="translate(0,${y(d.id)})">
      <text x="55" y="${y.bandwidth() / 2}" dy="0.35em" text-anchor="end">${d.key}</text>
      <rect x="60" height="${y.bandwidth()}" fill="${d.color}" width="${x(d.units) - 60}"></rect></g>`).join('');
  }

  const tally = d3.select('#tally');
  let i = 0;
  function step() {
    const m = months[i++ % months.length], data = unitsIn(m);
    joinDraw(data);  rebuildDraw(data);
    d3.select('#month').text(m);
    setTimeout(() => {                                    // after the observers have run
      if (i === 1) counts.join = counts.rebuild = 0;      // count updates only, not the first render
      const tx = d3.scaleLinear([0, Math.max(1, counts.join, counts.rebuild)], [0, 400]);
      tally.selectAll('g').data(Object.entries(counts)).join(enter => {
        const g = enter.append('g').attr('transform', (d, k) => `translate(0,${8 + k * 30})`);
        g.append('rect').attr('x', 150).attr('height', 22);  g.append('text').attr('class', 'l').attr('y', 15);
        g.append('text').attr('class', 'v').attr('y', 15);  return g;
      }).call(g => g.select('rect').attr('width', ([, n]) => tx(n)).attr('fill', ([k]) => k === 'join' ? '#3f7d3a' : '#b5452f'))
        .call(g => g.select('.l').text(([k]) => k === 'join' ? 'd3 join: DOM changes' : 'rebuild: DOM changes'))
        .call(g => g.select('.v').attr('x', ([, n]) => 155 + tx(n)).text(([, n]) => n));
    });
  }
  d3.select('#step').on('click', step);
  step();
  d3.interval(() => { if (d3.select('#auto').property('checked')) step(); }, 600);
</script>