Fixing a D3 Data Join That Won’t Update on Rebind

You call .data(newData).join(...) again with fresh values, the enter and exit phases fire, but the elements already on screen keep their old attributes — the update never lands.

This is almost always an identity problem in the join, and it lives one level down from the concepts in Data Joins & Key Functions, the companion guide that explains how selection.data() maps an array to nodes. When the rebind silently no-ops on existing elements, the cause is that D3 either matched the wrong nodes, or your update branch never sets the attributes that changed.

It is worth being precise about what “update” means in D3, because the bug usually hides in a misunderstanding of it. When you call .data(newData), D3 partitions the result into three selections: enter (data with no matching node), update (data whose key matched an existing node), and exit (nodes whose datum is gone). The update selection is the one that holds your already-rendered elements. If those elements never change, then either they did not land in the update selection in the first place (an identity matching failure), or they did land there but your code never wrote the changed attribute onto them (a branch-coverage failure). Every cause below reduces to one of those two.

The identity failure is the more insidious of the two because it is silent. With index binding, D3 always finds a match for the first min(oldCount, newCount) data points — they map to the same positions — so the update selection is never empty and no error is thrown. The elements simply carry the wrong data, and any attribute you do set is computed from a stale-then-overwritten datum, which can look correct for some fields and wrong for others. That partial-correctness is why these bugs survive code review: the chart is not blank, it is subtly wrong.

Diagnostic checklist

Verify these in order before touching render code; the first failing check is usually the bug.

How D3 decides what to update

Index binding versus keyed binding on a rebind With index binding the same node keeps stale data; with a key function the node follows its datum and the update applies. Index bind (no key) Keyed bind datum A datum B node[0] node[1] position fixed, data swapped = stale id 7 id 3 node id 7 node id 3 node follows its datum = update applies
Without a key function D3 keeps each node at its index; a key function makes the node track its datum so the update phase mutates the right element.

Broken vs fixed

// ❌ BROKEN: update phase never sets the value that changed.
import { select } from 'd3-selection';

function render(data: Array<{ id: string; v: number }>): void {
  select('g#bars')
    .selectAll<SVGRectElement, { id: string; v: number }>('rect')
    .data(data) // no key fn -> index binding; reordered data goes stale
    .join('rect') // shorthand: only appends new rects, sets nothing on existing ones
    .attr('width', 10); // width is set every time, but height (the data) is not
  // Result: re-running with new v values changes nothing visible.
}
// ✅ FIXED: stable key + explicit update branch that writes the changed attribute.
import { select } from 'd3-selection';

function render(data: Array<{ id: string; v: number }>): void {
  select('g#bars')
    .selectAll<SVGRectElement, { id: string; v: number }>('rect')
    .data(data, (d) => d.id) // A11Y: stable id also keeps aria-label bound to the right bar
    .join(
      (enter) => enter.append('rect').attr('width', 10),
      // PERF: update mutates in place — no node churn, no GC pressure on rebind.
      (update) => update,
      (exit) => exit.remove(),
    )
    // attributes set AFTER join apply to enter+update merged selection
    .attr('height', (d) => d.v) // the value that changed now lands on existing nodes
    .attr('y', (d) => 200 - d.v);
}

The key fix is twofold: a key function so D3 reuses the correct node, and writing the changed attribute on the merged selection returned by .join() (or in an explicit update callback) rather than only inside enter.

The subtlety that trips people is how .join('tag') shorthand and the three-argument .join(enter, update, exit) form differ. The string shorthand returns the merged enter-plus-update selection, so attributes you chain after it apply to both new and existing elements — which is exactly what you want for data-driven attributes. The three-argument form, by contrast, returns whatever each callback returns and merges them; if your update callback is the bare (update) => update, existing elements pass through untouched unless you set the changed attributes after the join() call. Both styles work, but mixing their mental models — expecting the three-argument update branch to inherit attributes set only in enter — is the single most common way the update silently does nothing.

Step-by-step fix

Verification

Confirm the update phase actually receives the existing nodes:

const sel = select('g#bars')
  .selectAll<SVGRectElement, { id: string; v: number }>('rect')
  .data(data, (d) => d.id);

// update selection = elements that matched an existing node by key
console.assert(sel.size() > 0, 'no existing nodes matched — key function mismatch');
// enter selection should be empty on a pure value-change rebind
console.assert(sel.enter().size() === 0, 'unexpected enters — keys are not stable');

In DevTools, inspect a bar’s __data__ property before and after the rebind: the bound object should be the new datum while the element node identity stays the same. If __data__ is stale, the key function is not matching.

Edge cases and gotchas

  • React StrictMode double-invoke. The render effect can run twice in development; if your first run binds with one key scheme and the second with another, updates desync. Pin the key function and key your effect on the data reference.
  • Numeric vs string keys. d.id of 1 and a node whose key was stringified to "1" will not match in all paths; normalize keys to strings before binding.
  • Selecting the wrong scope. selectAll('rect') from the root can grab rects from other groups, polluting the update selection; scope to the owning <g> so the join only sees its own elements.