Both diff, but different things at different times:
| 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.
<!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>