Enter-Update-Exit Pattern Mastery

Mishandle one phase of the lifecycle — most often a skipped merge or a missing exit removal — and you get phantom nodes, lost transitions, and a heap that grows until the tab stalls.

Concept overview

The enter-update-exit lifecycle is the deterministic reconciliation algorithm that maps a dataset onto DOM nodes or render primitives. After a keyed bind, D3 hands you three selections: enter (data with no node yet), update (data already bound to a node), and exit (nodes whose data is gone). selection.join() sugars the branching, but production dashboards need explicit control over .enter(), .merge(), and .exit() to choreograph transitions and manage allocation. This lifecycle is the reconciliation half of the broader D3.js data binding and layout architecture; it consumes the partition produced by data joins and key functions.

Which phases fire depends on how the data mutated:

  • Append: enter only — predictable and fast.
  • Replace: exit for removed keys, enter for new ones — requires GC discipline.
  • Splice / reorder: enter, update, and exit together — demands stable keys or nodes are needlessly recreated.

The reason the three selections exist as separate objects rather than a single diff is that each phase wants different attribute treatment, and conflating them is the root of most lifecycle bugs. Enter nodes start from nothing, so they need a neutral initial state (radius zero, opacity zero, the position they will grow from) before they animate to their final form; applying their final attributes immediately makes them pop into existence with no transition. Update nodes already exist with a known previous state, so they only need their changed attributes, and they are the only phase where an attribute transition reads as motion from a real prior value. Exit nodes are about to be destroyed, so they need a terminal animation and then removal — and crucially, they must be removed, because an exit node left in the DOM is a detached subtree the moment its transition ends. The enter and update phases share their final positioning, which is exactly what .merge() (or .join’s single update path) expresses: apply the shared, final attributes to both new and surviving nodes in one pass so they end up identically placed.

A precise way to think about it: the keyed bind computes a three-way set difference once, and each phase is a hook on one partition of that difference. enter is incoming − bound, update is incoming ∩ bound, exit is bound − incoming. Everything that goes wrong in this lifecycle — duplicates, missing positions, abrupt removals, memory growth — is one of these partitions being handled in the wrong place or not at all.

Enter, update, and exit selections over one bind The data join produces enter nodes that are appended and merged with update nodes, while exit nodes transition out before removal. enter append, r=0, opacity=0 merge(update) shared attrs + transition exit fade → remove() .merge() data(values, key) enter + update animate to final state; exit animates out, then detaches
One keyed bind fans out into three selections; enter is appended and merged with update for shared attributes, while exit transitions out before removal.

The hybrid form looks like this — .join() for safe branching, explicit per-phase tweens for distinct curves:

import { easeCubicOut, easeCubicIn } from 'd3-ease';

selection
  .selectAll<SVGRectElement, DataPoint>('rect.bar')
  .data(data, (d) => d.id) // stable key — identity preserved across updates
  .join(
    (enter) =>
      enter
        .append('rect')
        .attr('class', 'bar')
        .attr('y', baseline)
        .attr('height', 0) // neutral start so it grows in
        .attr('aria-label', (d) => `${d.id}: ${d.value}`) // A11Y: label at creation
        .call((e) =>
          e.transition('enter').duration(500).ease(easeCubicOut) // slow grow-in
            .attr('y', (d) => yScale(d.value))
            .attr('height', (d) => baseline - yScale(d.value)),
        ),
    (update) =>
      update.call((u) =>
        u.transition('update').duration(250) // faster slide for existing bars
          .attr('y', (d) => yScale(d.value))
          .attr('height', (d) => baseline - yScale(d.value)),
      ),
    (exit) =>
      exit.call((x) =>
        // PERF: named transition can be interrupted by key without disturbing enter/update
        x.transition('exit').duration(200).ease(easeCubicIn)
          .attr('height', 0).attr('y', baseline).remove(),
      ),
  )
  .order(); // keep DOM order aligned to data order for stacking and tab order

Walking one update through the diagram makes the choreography concrete. Suppose a bar chart is showing regions [A, B, C] and the data updates to [B, C, D]. With a stable key on region id, the bind computes: D is enter (new key), B and C are update (matched), A is exit (dropped key). The enter branch appends one new <rect> for D at a neutral height of zero so it can grow in. The merged enter-plus-update selection — D together with B and C — receives the shared final attributes: each bar’s x from the band scale and height from the value scale, applied through one transition so all three animate to their final geometry together. The exit branch takes A’s existing <rect>, transitions its height to zero, and removes it once the tween ends. The user sees A shrink away, B and C glide to their new positions, and D grow up from the baseline — all from one .data() call, because each partition was handled in its own phase. Swap the stable key for index binding and the same update instead reuses A’s node for B, B’s for C, and treats D as enter, so the bars animate to the wrong heights and A’s click handler ends up on what is now B.

Decision table: legacy chain vs modern join

Approach Code shape Merge handling Risk
Legacy chain .enter().append().merge(sel) manual, easy to drop double-append if .merge() is forgotten
selection.join() join(enter, update, exit) automatic less control over custom merge timing
Hybrid join() + explicit transition tween automatic + tuned most verbose, most precise

For most dashboards .join() is the default; reach for the explicit chain only when you need fine-grained transition timing per phase. The hybrid row is what large dashboards converge on: use .join() to get the safe enter/update/exit branching for free, but attach an explicit tween inside the enter and exit callbacks when a phase needs a custom curve, so you keep the brevity of the modern API and the precision of the legacy chain where it actually matters.

The trade is one of control versus brevity. selection.join('circle') in its shorthand form appends on enter, leaves update untouched, and removes on exit — a sensible default that handles the common case in one line. The three-function form join(enterFn, updateFn, exitFn) lets each phase do real work while still batching the whole thing into a single reconciliation pass, and the return value is the merged enter-plus-update selection so you can chain shared attributes after it. The legacy .enter().append().merge(sel) chain exposes the same machinery but makes the merge explicit, which is the only form that lets you give the enter and update phases genuinely different transition curves — for example, a slow grow-in for new bars and a fast slide for existing ones. The risk it carries is forgetting .merge(), in which case shared attributes apply only to enter nodes and existing nodes freeze in their old positions, or worse, applying .append() outside the enter branch and creating a duplicate on every cycle. Unless you need per-phase timing, prefer .join() and remove the chance of those mistakes entirely.

Reference spec

import type { Selection } from 'd3-selection';

interface DataPoint { id: string; value: number; timestamp: number; }

// Explicit enter-update-exit with a stable key.
function renderDataJoin(
  container: Selection<SVGSVGElement, unknown, null, undefined>,
  data: DataPoint[],
): void {
  const circles = container
    .selectAll<SVGCircleElement, DataPoint>('circle.data-node')
    .data(data, (d) => d.id); // stable key

  const enter = circles
    .enter()
    .append('circle')
    .attr('class', 'data-node')
    .attr('r', 0)
    .attr('aria-label', (d) => `Point ${d.id}: ${d.value}`); // A11Y: per-node label

  // MERGE: enter + update share attribute application.
  enter
    .merge(circles)
    .attr('cx', (d) => d.timestamp)
    .attr('cy', (d) => d.value)
    .attr('fill', '#2563eb');

  // EXIT: animate out, then detach. PERF: prevents detached-DOM leaks.
  circles.exit().transition().duration(200).attr('r', 0).attr('opacity', 0).remove();
}

The relevant v7 signatures make the phase ownership explicit:

// d3-selection v7
selection.enter(): Selection<EnterElement, Datum, PElement, PDatum>;  // placeholder nodes
selection.exit():  Selection<GElement, Datum, PElement, PDatum>;      // real, attached nodes
selection.merge(other: Selection): Selection;                         // union of two selections
selection.join(
  enter: string | ((e: Selection) => Selection),
  update?: (u: Selection) => Selection | undefined,
  exit?:  (x: Selection) => void,                                     // return value ignored
): Selection;                                                         // returns merged enter+update
selection.order(): Selection;                                         // reorder DOM to match data

Two of these are easy to forget. selection.order() re-sorts the DOM children so their document order matches the bound data order; after a keyed reorder the nodes are still bound correctly but may paint in their old stacking order, which matters for overlapping marks and for tab order, so call .order() on the merged selection when sequence is semantically meaningful. And selection.exit() returns real nodes, not placeholders — they are still in the DOM and still consuming layout until you remove them, which is why an exit transition that never reaches .remove() (because a new bind interrupts it) silently accumulates detached subtrees. Naming the transition with .transition('exit') lets a subsequent bind interrupt that specific transition by name without disturbing an unrelated enter transition on the same nodes.

Step-by-step implementation

The sequence below is ordered to make each phase’s responsibility unmistakable: bind, give enter a neutral starting state, merge before applying shared geometry, position with cached scales, animate exits, and chunk if the batch is large. The single most common deviation — and the source of the “entering nodes have no position” bug — is applying shared attributes to the enter selection alone instead of to the merged selection, so the merge step is load-bearing, not decorative.

import { select, type Selection } from 'd3-selection';

function progressiveJoin(
  container: Selection<SVGSVGElement, unknown, null, undefined>,
  data: DataPoint[],
  chunkSize = 500,
): void {
  let index = 0;
  function processNextChunk(): void {
    if (index >= data.length) return;
    renderDataJoin(container, data.slice(0, index + chunkSize));
    index += chunkSize;
    // PERF: yield to the compositor between chunks to hold the frame budget.
    requestAnimationFrame(processNextChunk);
  }
  requestAnimationFrame(processNextChunk);
}

Performance & memory notes

The dominant cost in this lifecycle is not the join arithmetic — that is two linear passes — but the layout the browser performs in response to the attributes each phase writes. An enter that appends a thousand <rect> elements and sets their width/height triggers layout for the inserted subtree; an update that rewrites x/y reinvalidates layout for the moved nodes; an exit that transitions height to zero reinvalidates on every frame of the tween. The lever is the same one that governs the whole pipeline: prefer compositor properties (transform, opacity) over geometry attributes for animated phases, batch all DOM writes into one frame, and never read layout (getBoundingClientRect, offsetWidth) between writes inside the loop.

  • Complexity: a keyed join is O(n) to build the key map plus O(n) to apply attributes; .merge() adds no extra pass.
  • GC pressure: each un-removed exit node is a detached subtree retained by D3’s __data__ pointer. In long sessions these compound into stop-the-world GC pauses.
  • Frame budget: keep exit transitions under 200ms, animate only opacity/transform to stay on the compositor, and stagger bulk enters with .delay((_, i) => i * 15) to spread layout cost.
  • Cross-context: on Canvas, the “join” is a diff against a state array driving a single clearRect + redraw; on WebGL it maps to bufferSubData updates against pre-allocated Float32Array buffers and batched drawArrays.
  • Chunking large enters: appending 50k nodes in one synchronous pass blocks layout for the whole batch and blows the frame budget. The progressiveJoin helper above spreads the work across frames by binding a growing prefix of the data each requestAnimationFrame, trading a slightly longer total time for a responsive UI; an alternative is to render the dense layer off-DOM (Canvas/WebGL) and keep only interactive marks as real nodes.
  • Transition memory: every active transition holds a small per-node schedule object. Thousands of concurrent staggered transitions are themselves an allocation source; cap stagger fan-out and prefer one transition over many short ones where the visual result is equivalent.

Accessibility checklist

The lifecycle interacts with assistive technology in a way that is invisible on screen but jarring with a screen reader or keyboard. Set semantic attributes — role, aria-label, tabindex — in the enter branch where you already set initial geometry, so every node is accessible from the moment it exists rather than after a follow-up pass. When a node exits while it holds keyboard focus, the browser drops focus to <body> and a keyboard user is stranded; before removing a focused exit node, move focus to a surviving sibling or the container. Because a stable key keeps a node alive across updates, the same keying discipline that prevents duplicates also preserves focus, which is why accessibility and identity are the same problem viewed from two angles. Finally, exit and enter animations must honor prefers-reduced-motion: collapse durations to zero so the chart updates instantly for users who have asked the OS not to animate, rather than fading marks in and out.

Troubleshooting

These are the failures that show up in practice, ordered by how often they reach a bug tracker. Each maps to one phase being handled in the wrong place — the recurring theme of this lifecycle.

  • Symptom: elements duplicate every update. Root cause: legacy chain without .merge(), or index binding. Fix: use .join() with a stable key — full walkthrough in fixing duplicate nodes in D3 enter-update-exit.
  • Symptom: entering nodes lack position/color. Root cause: shared attributes applied before merge. Fix: set shared attrs on the merged selection.
  • Symptom: nodes vanish abruptly. Root cause: exit().remove() without a transition. Fix: animate exit, then remove — see animating enter and exit selections smoothly.
  • Symptom: heap climbs. Root cause: retained listeners on detached nodes, or exit transitions interrupted before .remove() runs. Fix: remove exits, name exit transitions so a new bind interrupts them by name, and key node caches with WeakMap so removed nodes are reclaimable.
  • Symptom: marks paint in the wrong stacking order after a reorder. Root cause: keyed bind preserved identity but not document order. Fix: call selection.order() on the merged selection after applying attributes so DOM order matches data order.

Frequently Asked Questions

Do I still need .merge() if I use .join()?

No — selection.join() applies enter and update internally, so you do not call .merge() yourself. You only need the explicit .enter().append().merge() chain when you want to control transition timing differently for the enter and update phases, which join’s single update callback does not express as cleanly.

Why do my entering elements have no position?

Because shared attributes were set on the enter selection alone instead of the merged selection. Append enter nodes with their neutral starting state, then call .merge(update) and set position and color on the combined selection so both new and existing nodes are placed identically.

How do I avoid abrupt removals?

Animate the exit selection to a collapsed or transparent state before .remove(): exit().transition().duration(200).attr(‘opacity’, 0).remove(). The node stays in the DOM only until the transition ends, then detaches, so there is no layout snap.

How does this lifecycle map to Canvas or WebGL?

There is no DOM to enter or exit, so you diff incoming keys against a state array (Canvas) or typed-array buffer (WebGL) to derive the three sets manually. Enter and update write into the buffer, exit removes entries, and a single redraw or draw call paints the result per frame.