D3.js Data Joins: What the Key Function Decides, and What join() Hides
A key function decides whether D3 reuses DOM nodes or rebuilds them. Here's how selection.data().join() handles that, and the exit behavior it hides.
25 Nov 2025, 09:39 UTC

The symptom: bars that flicker instead of moving
You have a bar chart that refreshes every few seconds. The values change, but the bars jump: transitions restart from zero, hover tooltips attach to the wrong bar, and one bar sometimes appears to teleport. The data is correct. The problem is node identity.
By default, selection.data() matches data to DOM elements by array index. When the array is reordered — a new item inserted, a sort applied — index 3 still points at the same <rect>, but that element now carries a different datum. Any transition already running on it continues toward a target that no longer belongs to it.
The fix is a key function, and .join() is the wrapper that makes the resulting update cycle readable. But join() also hides parts of the enter/update/exit model, and one of those hidden parts catches people out.
What join() replaces
Before join(), a standard update looked like this — and this form still works in D3 v7:
const sel = svg.selectAll("rect").data(data, d => d.id);
sel.exit().remove();
sel.enter().append("rect")
.attr("class", "bar")
.merge(sel) // merge enter + update before styling
.attr("x", d => x(d.id));
Three selections, three code paths, and a merge() call that is easy to forget. join() collapses them into one chain:
svg.selectAll("rect")
.data(data, d => d.id)
.join("rect")
.attr("x", d => x(d.id));
Passing a string to join() means "append this element for every entering datum." The method returns the update selection merged with the newly entered elements, so chained attribute, style, and transition calls apply uniformly to old and new nodes.
Version assumption: selection.join() arrived in the D3 v5 line (d3-selection 1.4) and is present in v6 and v7. It does not exist in v3 or v4. If you maintain a mixed codebase, check d3.version before copying a snippet, and treat this paragraph as needing confirmation against your installed bundle.
The key function is the real decision
The second argument to data() is a function that returns a string identifier for a datum. D3 calls it for every existing element (with this bound to the element and its old datum available) and for every incoming datum, then matches by the returned value. Matching keys preserve the same DOM node across updates — this is what people mean by object constancy.
Two practical consequences:
- Transitions target persistent nodes, so a bar animates from its previous height rather than being destroyed and recreated.
- Element state that D3 does not manage — focus, a CSS class toggled elsewhere, a canvas overlay keyed to the node — survives the update.
The cost is that the key function runs for every datum on every update. Keep it pure and cheap: d => d.id is fine; d => JSON.stringify(d) is not, especially on large datasets.
Worked example: updating bars without recreating them
Run this in a browser page that has loaded D3 v7 (or v5.1+) and contains <svg id="chart"></svg>. No special permissions are involved; it is client-side code.
const svg = d3.select("#chart");
const width = 480, height = 240, baseline = height;
function render(data) {
const x = d3.scaleBand()
.domain(data.map(d => d.id))
.range([0, width])
.padding(0.15);
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value) || 1])
.range([height, 0]);
svg.selectAll("rect.bar")
.data(data, d => d.id) // stable identity
.join(
enter => enter.append("rect")
.attr("class", "bar")
.attr("y", baseline) // enter collapsed at the axis
.attr("height", 0),
update => update,
exit => exit
.transition().duration(200)
.attr("y", baseline)
.attr("height", 0)
.remove()
)
.attr("data-id", d => d.id)
.transition().duration(300)
.attr("x", d => x(d.id))
.attr("width", x.bandwidth())
.attr("y", d => y(d.value))
.attr("height", d => baseline - y(d.value));
}
render([{ id: "a", value: 30 }, { id: "b", value: 70 }]);
render([{ id: "b", value: 40 }, { id: "c", value: 90 }]);
Expected checks after the second call: the rect for b is the same DOM node as before and animates from its old height; a shrinks to the baseline and is removed; c is appended collapsed and grows. The data-id attribute is set in the merged chain so it applies to entered and updated nodes alike.
Trade-offs and limits
- Exit handling moves into a callback. If you omit the exit argument entirely,
join()removes exiting elements by default. Once you supply an exit function, removal is your responsibility — forgetting.remove()leaves orphaned nodes in the DOM. - Debugging gets less obvious. The separate enter, update, and exit selections are no longer named variables, so inspecting which branch a node came from means temporarily rewriting the call in the long form.
- join() manages identity, not layout. Scales, axes, and any other derived state still need their own update path. A keyed join will not stop an axis from re-rendering on every tick.
How to confirm the key is doing its job
In the browser console, capture a node reference before and after an update that keeps the same id:
const before = d3.select('#chart rect[data-id="b"]').node();
render(nextData); // nextData still contains id "b"
const after = d3.select('#chart rect[data-id="b"]').node();
console.log(before === after);
A true result means the node was reused. Remove the key function and repeat: the comparison should flip, because index-based binding reassigns data to whichever element sits at that position. The same check works in the Elements panel — select a node, trigger an update, and see whether the highlighted element is the one that moved.
If you are maintaining pre-v5 code, the equivalent verification is to log sel.enter().size() and sel.exit().size() around each update; those counts tell you whether D3 is matching by identity or rebuilding from scratch.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.