Fixing Duplicate Nodes in D3 Enter-Update-Exit
Your D3 chart appends a fresh set of shapes on every update instead of reusing the existing ones, leaving overlapping, unresponsive duplicates that bloat the DOM and confuse screen readers.
This is almost always an identity-resolution failure in the join. The fix lives in the enter-update-exit pattern, and understanding why requires the keyed-bind mechanics from data joins and key functions.
The symptom is specific enough to be diagnostic on its own: node count grows on every update instead of staying constant, the new nodes sit exactly on top of the old ones, and click or hover handlers fire two or three times because several stacked elements receive the same event. None of that is a rendering bug — the renderer is faithfully drawing every node it was told to create. The defect is upstream, in how the join decides which incoming records already have a node, so the cure is always to fix the binding rather than to deduplicate the DOM after the fact. Removing duplicates with a post-hoc selectAll().filter() sweep treats the symptom and guarantees the bug returns on the next cycle.
Diagnostic checklist
Root cause analysis
Duplicates are architectural symptoms of identity-resolution failure, not random glitches. There are four distinct causes, but they all reduce to the same failure: .enter() runs for a datum that already owns a node. Knowing which of the four you have determines the fix, so read them as a differential diagnosis rather than a list.
- Index binding default: with no key, D3 matches by array position. After a sort, filter, or insert, shifted items look like new entities, so
.enter()fires for elements that already exist and the old nodes are stranded. - Missing merge: the legacy chain only appends in the enter branch. Forget
.merge()and each cycle appends again while never updating existing nodes. - Unstable keys: a key returning
undefinedor colliding IDs makes D3 fall back to index behavior, instantly re-duplicating on the next cycle. - Framework double-mount: React StrictMode and hot-module replacement run effects twice in development, appending a second subtree before cleanup runs.
The unifying diagnosis is that .enter() is firing for data that already has a node. That can only happen for one of two reasons: D3 could not match the incoming datum to an existing node (because the key is missing, unstable, or positional and the array shifted), or there was never an update/merge path so the enter branch is the only branch that ever runs. Distinguishing the two takes one line: log selection.enter().size() and selection.size() right after the bind. If the enter size equals the full data length on a second, otherwise-unchanged update, identity matching failed — it is a key problem. If the enter size is correct but nodes still accumulate, the merge/update path is missing and existing nodes are never being reused — it is a structural problem. The fix differs accordingly, so make this measurement before changing code.
Broken vs fixed
// ❌ BROKEN: index binding, legacy chain, redundant re-selection, no exit cleanup
svg
.selectAll('circle')
.data(newData) // defaults to index binding
.enter()
.append('circle') // appends every cycle
.attr('r', 5)
.merge(svg.selectAll('circle') as any) // re-selects ALL circles, including stale ones
.attr('cx', (d: Point) => d.x); // exits never removed → duplicates accumulate
// ✅ FIXED: stable key, modern .join(), explicit three phases
interface Point { id: string; x: number; y: number; }
const circles = svg
.selectAll<SVGCircleElement, Point>('circle')
.data(newData, (d) => d.id); // stable, unique identifier
circles.join(
(enter) =>
enter.append('circle')
.attr('r', 5)
.attr('cx', (d) => d.x)
.attr('cy', (d) => d.y),
(update) =>
update.transition().duration(300) // reuses existing nodes — no duplicate append
.attr('cx', (d) => d.x)
.attr('cy', (d) => d.y),
(exit) => exit.remove(), // deterministic cleanup, no stranded nodes
);
.join() replaces the .enter().append().merge() chain, so the update branch only ever sees existing, correctly-bound elements and the enter branch only fires for genuinely new keys.
Two details in the broken version are worth dissecting because they each independently cause duplication. The bare .data(newData) with no second argument is index binding, so after any sort the same array position holds a different logical record and D3 mismatches every node. The .merge(svg.selectAll('circle')) line is a second, compounding bug: re-selecting all circles inside the merge pulls in the stale nodes from previous cycles that were never removed, so the chain operates on an ever-growing set. The fixed version eliminates both by keying .data() on d.id and letting .join() own the three phases — the enter callback runs only for unmatched keys, the update callback only for matched ones, and the exit callback removes the rest, so the node set stays exactly the size of the data.
Step-by-step fix
Apply these in order; the first two resolve the overwhelming majority of cases, and the rest harden against the framework and data-quality edges that reintroduce duplicates after the obvious fix.
- Add a stable key. Change
.data(newData)to.data(newData, (d: Point) => d.id). Confirmd.idis unique and reference-stable. - Switch to
.join(). Replace the legacy chain with the three-argumentselection.join(enter, update, exit)form. - Sanitize the data. Drop records with missing IDs before binding:
const clean = newData.filter((d) => d.id != null). - Guard framework double-mounts. In React, store an init flag in a
useRefand remove D3 nodes in theuseEffectcleanup so StrictMode’s second pass starts clean. - Animate exits safely. Use
exit().transition().duration(300).style('opacity', 0).remove()so removal completes after the animation, not before.
import { useEffect, useRef } from 'react';
import { select } from 'd3-selection';
function Chart({ data }: { data: Point[] }): JSX.Element {
const ref = useRef<SVGSVGElement>(null);
useEffect(() => {
const svg = select(ref.current!);
const clean = data.filter((d) => d.id != null); // step 3
svg.selectAll<SVGRectElement, Point>('rect.node')
.data(clean, (d) => d.id) // steps 1–2
.join(
(enter) => enter.append('rect').attr('class', 'node'),
(update) => update,
(exit) => exit.remove(),
);
// PERF: cleanup removes D3 nodes before StrictMode's second mount re-runs.
return () => { svg.selectAll('.node').remove(); }; // step 4
}, [data]);
return <svg ref={ref} role="img" aria-label="Live nodes" />; // A11Y: container role + label
}
Verification
- Count assertion: right after the bind, run
console.assert(svg.selectAll('rect.node').size() === clean.length, 'duplicate nodes present'). Run it across several updates, not just the first, since duplication compounds over cycles — a single render can look correct while the third looks doubled. - Enter-size check: log
selection.enter().size()on an update where the data did not structurally change; it should be0. A non-zero value on an unchanged rebind is the definitive signature of a key failure. - DOM inspection: in DevTools Elements, select a duplicated shape and read its
__data__property — duplicates show stale or mismatched data references, and two elements bound to the same datum confirm a missing key. - Memory: take heap snapshots before and after several updates; the detached-node count must return to zero after a forced GC, confirming exits were removed rather than stranded.
- Visual diff: overlapping shapes vanish; each datum maps to exactly one rendered mark, and event handlers fire once per click rather than two or three times.
Edge cases & gotchas
- Nested selections: grouped joins need matching keys at both parent and child
.selectAll()levels, or children duplicate inside each group. The outer join keys the groups (for example, one<g>per series) and the inner join keys the marks within each group; forgetting the inner key re-appends every child on every update even though the parent reconciled correctly, so the duplication hides one level down where it is easy to overlook. - Transition vs exit timing:
.transition().remove()removes only after the tween ends; if a new bind arrives mid-transition, interrupt first so half-faded exits do not linger. - Type coercion in keys: D3 coerces keys with
String(), so the number1and the string"1"collapse to one key while aDateand itsgetTime()do not. Normalize key types at ingestion so numeric and string IDs neither double-bind nor falsely merge. - Merged feeds: combining two data sources whose ids are per-source counters produces key collisions, where one entity silently shadows another and the loser appears to duplicate or vanish. Namespace the key with its source —
`${source}:${row.id}`— so identities stay disjoint. - StrictMode timing: the double mount appends a subtree, runs cleanup, then mounts again; if your cleanup only removes nodes matching a class you forgot to set on enter, the first subtree survives. Make the teardown remove the entire container’s children, or scope it to a selector you are certain every enter node carries.
Related
- Enter-update-exit pattern mastery — the parent guide for this lifecycle.
- Data joins and key functions — why stable keys prevent duplication.
- Animating enter and exit selections smoothly — safe exit transitions.