Syncing DOM Elements to Data with D3's Enter-Update-Exit Join
Use D3 v7's data join to add, update, and remove DOM elements as your dataset changes, with a key function to prevent mismatched bindings.
10 Sept 2025, 04:33 UTC

The problem: data changes, but the DOM does not
Imagine a dashboard that lists server status cards. You fetch an array, render one card per server, and everything looks right. Then the array changes: a server is added, another is removed, and two swap positions. If you simply append new elements or re-render blindly, the DOM fills with orphaned cards or, worse, cards that show the wrong server's status because D3 matched data to elements by array position.
The useful takeaway: use D3's selection.data() with a key function, handle the enter and exit selections explicitly, and merge enter with update so shared attributes are applied once. This keeps the document in sync with the dataset without manually tracking which nodes to create or delete.
What the data join produces
In D3 v7, calling d3.selectAll(".card").data(array) binds each array item to an existing element. D3 divides the result into three groups:
- Update: elements that have matching data already in the DOM.
- Enter: data items with no matching element; these need new nodes appended.
- Exit: elements with no matching data; these should be removed.
A selection is D3's wrapper around one or more DOM nodes. The enter and exit concepts only exist after a data join; they are not separate D3 methods you call on a bare selection.
Index binding versus key binding
Without a second argument, data() binds by array index. If the first item moves to the end of the array, its element stays in place and receives the last item's values. A key function fixes this by assigning a stable identity to each datum:
.data(servers, d => d.serverId)
Now D3 matches elements by serverId instead of position, so reordering the array moves the correct data to the correct element.
Worked example: syncing status cards
Run this in your browser's DevTools console on a page that loads D3 v7. No special permissions are required.
// Assumes D3 is loaded as the global d3 and a container exists.
// Replace "#panel" and ".card" with your actual selectors.
const container = d3.select("#panel");
const servers = [
{ serverId: "s-1", name: "db-primary", ok: true },
{ serverId: "s-2", name: "cache-01", ok: false },
{ serverId: "s-3", name: "api-gateway", ok: true }
];
// 1. Join data to existing elements, keyed by serverId
const update = container.selectAll(".card").data(servers, d => d.serverId);
// 2. Remove elements for servers that disappeared
update.exit().remove();
// 3. Append new cards for servers that have no element yet
const enter = update.enter()
.append("div")
.attr("class", "card")
.attr("data-server-id", d => d.serverId);
// 4. Apply shared styles to both new and existing cards
enter.merge(update)
.text(d => `${d.name}: ${d.ok ? "ok" : "down"}`)
.style("color", d => d.ok ? "green" : "red");
Expected check: open the Elements panel and confirm the number of .card elements equals the length of servers, and that each card's text matches its server. If you reorder servers and re-run the join, the cards should keep their data-server-id association rather than shuffling values by index.
Risk: exit().remove() deletes nodes immediately. If your key function returns unstable or duplicate values, D3 may remove the wrong elements or fail to match them at all.
Limitation: scale and key stability
The enter/update/exit pattern is stable across D3 v5, v6, and v7, but it is not free. Re-binding thousands of elements on every change can cost more than a targeted update. The pattern also depends on your key function returning unique, stable values; using an array index as the key defeats the purpose when data can reorder. For very large or frequently changing datasets, consider whether a full D3 join is the right tool or whether a virtualized list or framework-managed DOM is more appropriate.
Verify the result
- Paste the example into the console on a page with D3 v7 loaded.
- Inspect
document.querySelectorAll("#panel .card").length; it should equalservers.length. - Change
servers, re-run the join, and confirm removed servers disappear from the DOM and added servers appear. - Refresh the page to roll back any DOM changes made by the example.
Bottom line
Do not manage added and removed DOM nodes by hand. Let D3's data join tell you what entered, what exited, and what stayed, then handle each group explicitly. Bind by a stable key, remove the exit selection, and merge enter with update for shared attributes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.