Use a keyed D3 data join to stop reordered data from reusing the wrong DOM nodes
A practical guide to D3’s data join pattern: bind data by identity, handle enter and exit selections, and merge updates without reusing the wrong nodes.
24 Jun 2026, 10:55 UTC

The bug: reordered data changes the wrong rows
You render a small D3 list or chart. A user reorders or filters the dataset. Instead of nodes moving to their new positions, existing nodes keep their old DOM identity and receive new values. Labels end up attached to the wrong rows, and removed items may stay on screen.
The useful takeaway is not a rewrite. Treat every render as a join between data and DOM: bind by identity when identity matters, remove exit nodes, append enter nodes, and apply shared updates to the merged selection.
What selection.data() actually returns
In D3 v4 through v7, selection.data(values) binds an array to selected elements and returns the update selection: elements that already existed and matched data. You then call .enter() for data values with no matching element and .exit() for elements with no matching data.
D3 does not use a virtual DOM and does not automatically add or remove nodes. Enter and exit are selections you must handle yourself. If you omit the key function, D3 matches by index. That is fine for a simple append-only list, but reordering can make position one receive the value formerly at position two.
The key function changes the matching rule. selection.data(values, d => d.id) binds data by identity instead of position. That makes joins stable when the dataset is reordered or filtered, and it also makes transitions track the same logical item instead of the same slot.
Worked example: a keyed row list
Save the following as join.html. It uses D3 v7 from a CDN and exposes d3 on window so you can inspect the join from the browser console.
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>D3 keyed data join</title>
<style>
.row { display: flex; gap: 8px; padding: 4px; border-bottom: 1px solid #ddd; }
.label { width: 80px; }
.value { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id='list'></div>
<button id='reorder'>Reorder</button>
<button id='change-set'>Change set</button>
<script type='module'>
import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';
window.d3 = d3;
const list = d3.select('#list');
let data = [
{ id: 'a', label: 'Alpha', value: 3 },
{ id: 'b', label: 'Bravo', value: 5 },
{ id: 'c', label: 'Charlie', value: 8 }
];
function render(rowsData) {
const update = list.selectAll('div.row').data(rowsData, d => d.id);
update.exit().remove();
const enter = update.enter()
.append('div')
.attr('class', 'row');
enter.append('span').attr('class', 'label');
enter.append('span').attr('class', 'value');
const merged = update.merge(enter).order();
merged.select('.label').text(d => d.label);
merged.select('.value').text(d => d.value);
}
render(data);
d3.select('#reorder').on('click', () => {
data = [data[2], data[0], data[1]];
render(data);
});
d3.select('#change-set').on('click', () => {
data = [
{ id: 'b', label: 'Bravo', value: 6 },
{ id: 'd', label: 'Delta', value: 2 }
];
render(data);
});
</script>
</body>
</html>
Run a static server from the directory containing join.html; no elevated permissions are needed:
python3 -m http.server 8000
Open http://localhost:8000/join.html. If port 8000 is already in use, replace it with another port and update the URL.
How to check the join
In DevTools console, inspect the bound data and node count:
d3.selectAll('div.row').size()
d3.selectAll('div.row').data().map(d => d.id)
Expected checks: after clicking Reorder, the ids should be ['c', 'a', 'b'] and the row count should stay at three. After clicking Change set, the row count should drop to two and the ids should be ['b', 'd']. Inspect the DOM to confirm that removed rows are gone and that Delta was appended rather than overwriting an unrelated node.
Index joins vs keyed joins
| Join style | Matching rule | Best for | Failure mode |
|---|---|---|---|
.data(rows) | Position in the array | Append-only or fixed-length series | Reordering or filtering reuses nodes for different values |
.data(rows, d => d.id) | Stable identity | Reorderable, filtered, or animated lists | Duplicate or unstable keys break matching |
One easy detail to miss: a keyed join does not reorder the DOM by itself. In the example, merged.order() moves the rows so the DOM order matches the data order. Without it, values stay attached to the right ids, but the visual order may not change.
Limits and practical trade-offs
- Keys must be stable and unique. If two rows share an id, or if the id changes on every render, the join cannot track identity reliably.
- Enter and exit are manual. D3 will not append missing nodes or remove stale nodes unless you call the enter and exit selections.
- No virtual DOM or automatic reactivity. You decide when to run the join and what to update.
- Version matters. This pattern is consistent in D3 v4 through v7, but D3 v3 used a different selection API, so v4+ examples will not run unchanged in v3.
- CDN dependency. The example imports D3 v7 from a CDN. If the page must work offline, vendor a local D3 build instead.
D3 v6+ also offers selection.join as shorthand sugar, but the explicit enter/exit/merge pattern is still useful when enter needs child structure and exit needs cleanup. To undo the example's state changes, reload the page; in a real app, keep the previous dataset and re-render it if you need a rollback path.
Practical rule: use an index join for simple fixed lists, and a keyed join whenever users can reorder, filter, add, remove, or animate the same logical items.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.