Binding Nested Data with selectAll
You bind an array of series, append a <g> per series, then bind each series’ points inside — and every group renders the same points, or the second-level join throws, because the nested selectAll is reaching across group boundaries instead of staying inside its parent.
This guide is part of D3 data joins and key functions, extending the flat one-level join to hierarchical data: a grouped bar chart, a multi-series line chart, a small-multiples grid. The mechanism is a two-level join where the inner selectAll().data(d => d.values) reads its data from the parent datum, and the whole thing hinges on how D3 scopes a selectAll to its group and how key functions preserve identity at both levels.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: inner selectAll not scoped to groups; static array; no keys.
import { select } from 'd3-selection';
interface Series { key: string; values: { x: number; y: number }[]; }
const data: Series[] = load();
const groups = select('svg').selectAll('g').data(data).join('g');
// Wrong: selecting rects on the whole svg, binding the FIRST series' values to all:
select('svg').selectAll('rect') // reaches across every group
.data(data[0].values) // same array for everyone
.join('rect'); // every group shows series 0's bars
// ✅ FIXED: inner join scoped per group, function accessor, keyed at both levels.
import { select, Selection } from 'd3-selection';
interface Point { x: number; y: number; }
interface Series { key: string; values: Point[]; }
function renderNested(svg: SVGSVGElement, data: Series[]): void {
const groups: Selection<SVGGElement, Series, SVGSVGElement, unknown> =
select(svg)
.selectAll<SVGGElement, Series>('g.series')
.data(data, (s: Series) => s.key) // outer key: stable series identity
.join((enter) => enter.append('g').attr('class', 'series'));
// Inner join runs ON the group selection, so selectAll is scoped to each <g>.
groups
.selectAll<SVGRectElement, Point>('rect')
.data(
(d: Series) => d.values, // function: each group's OWN subarray
(p: Point) => String(p.x), // inner key: unique WITHIN the group
)
.join(
(enter) => enter.append('rect'),
(update) => update,
(exit) => exit.remove(), // PERF: prune per-group orphans
)
.attr('x', (p: Point) => p.x)
.attr('height', (p: Point) => p.y);
// A11Y: label each group with aria-label = series key for screen-reader grouping.
}
The whole fix is in two words: scoped and function. Because the inner selectAll is called on groups (the group selection), D3 runs it once per group and confines each match to that group’s subtree — no rect from series B can match while processing series A. And because .data() receives the function d => d.values rather than a static array, D3 invokes it once per group with that group’s bound datum d, handing each group its own subarray. Miss either and every group renders identical content.
Step-by-step fix
// Reaching the parent datum from inside the inner join, when a bar needs its series color.
import { Selection } from 'd3-selection';
interface Point { x: number; y: number; }
interface Series { key: string; color: string; values: Point[]; }
function colorByParent(
rects: Selection<SVGRectElement, Point, SVGGElement, Series>,
): void {
// The second callback arg is the PARENT datum (the series), available in nested joins.
rects.attr('fill', function (this: SVGRectElement, _p: Point): string {
const parent = (this.parentNode as SVGGElement & { __data__: Series }).__data__;
return parent.color; // each group's bars inherit the series color
});
}
Verification
import { select } from 'd3-selection';
renderNested(svg, [
{ key: 'A', values: [{ x: 0, y: 5 }, { x: 1, y: 3 }] },
{ key: 'B', values: [{ x: 0, y: 2 }] },
]);
const groups = select(svg).selectAll('g.series');
console.assert(groups.size() === 2, 'one group per series');
// Each group must hold ONLY its own points, not the union.
console.assert(select(svg).select('g.series').selectAll('rect').size() === 2, 'series A has 2 bars');
In DevTools, expand the SVG in the Elements panel and confirm each <g class="series"> contains exactly its own series’ rects, and that inspecting a rect shows a __data__ matching that series’ subarray. If every group holds the same rects, the inner .data() received a static array instead of the d => d.values function.
How selectAll scopes the nested join
The behaviour that makes nested joins work — and that surprises people coming from a flat join — is that selectAll operates per group and preserves the grouping structure. A D3 selection is not a flat list of elements; it is an array of groups, each with a parent node. When you call .selectAll('rect') on a selection of three <g> elements, D3 runs the match three times, once inside each <g>, and produces a new selection of three groups whose parents are those <g> elements. That is precisely why the subsequent .data(d => d.values) can hand each group a different array: D3 invokes the accessor once per parent group, passing that parent’s bound datum. The grouping is the plumbing that carries the parent datum down to the inner join.
This also explains the classic gotcha that .select and .selectAll propagate data differently. selection.select('rect') propagates the parent’s datum to the matched child, which is why a single-child pattern sometimes “just works” without an inner .data(). But selection.selectAll('rect') deliberately does not propagate data — it starts a fresh grouping that expects its own .data() call — because a parent has many children and there is no single datum to copy. Using select where you needed selectAll gives you one element carrying the parent’s datum; using selectAll without a following .data() gives you an empty inner selection. The nested pattern always pairs selectAll with a function-accessor .data().
Keys at two levels
Identity matters twice in a nested join, and the two key functions have different scopes. The outer key identifies each series across rebinds — when the data refreshes with the series in a different order, an outer key of s => s.key keeps series A’s group, its color, its event listeners, and any in-flight transition attached to series A rather than swapping to whatever now sits at index 0. The inner key identifies each point within its series, and it only needs to be unique inside that group, so a per-point x or category is enough even if the same x recurs in another series. Getting either wrong produces the same visible symptom as a flat join gone wrong: marks that flicker, swap, or fail to update on rebind. When a nested rebind appears to do nothing, the cause is almost always a missing or unstable key at one of the two levels — the same diagnosis as fixing a D3 data join not updating on rebind, just applied one level deeper.
Edge cases & gotchas
selectvsselectAlldata propagation.selectcopies the parent datum to the child;selectAlldoes not and requires its own.data(). Mixing them up is the top cause of “the inner join is empty”.- Static array in the inner
.data(). Passingdata[0].valuesinstead ofd => d.valuesbinds the same subarray to every group, so all groups render identically. - Globally-unique inner keys. The inner key only needs uniqueness within its group; forcing global uniqueness (e.g.
series+x) is fine but unnecessary and can mask a genuine cross-series match bug. - Reaching the parent datum. Inside the inner join, the second argument to attribute callbacks is the index, not the parent; get the parent datum from
this.parentNode.__data__or capture it in a closure from the outer.each. - Three-level nesting. Small-multiples-of-grouped-bars needs three levels; the same rule recurses — each level’s
selectAllruns on the previous level’s selection with its own function accessor and key. - Exit at the inner level. Forgetting
.exit().remove()on the inner join leaves stale bars in a group when a series loses points, a per-group leak that is easy to miss because the outer join looks correct.
Frequently Asked Questions
Why does every group render the same data in my nested D3 join?
Because the inner .data() received a static array instead of a function. Pass d => d.values so D3 invokes the accessor once per group with that group’s bound datum and hands each group its own subarray. Also confirm the inner selectAll is called on the group selection, not the document root, so it stays scoped to each group’s subtree.
What is the difference between select and selectAll for nested data?
selection.select propagates the parent’s datum to the single matched child, so a one-child pattern can work without an inner .data(). selection.selectAll does not propagate data — it begins a fresh grouping that expects its own .data() call — because a parent has many children with no single datum to copy. Nested joins always pair selectAll with a function-accessor .data().
How do key functions work in a nested join?
There are two, with different scopes. The outer key identifies each series across rebinds so its group, color, and transitions stay attached when the data reorders. The inner key identifies each point within its series and only needs to be unique inside that group, so a per-point x or category suffices even if the same value recurs in another series. A missing key at either level causes marks to flicker or fail to update.
Related
- D3 data joins and key functions — the parent guide on identity resolution.
- Fixing a D3 data join not updating on rebind — the key-function diagnosis that applies at each nested level.
- D3 enter-update-exit pattern mastery — the lifecycle every level of the join runs.
- D3 scales and axes configuration — positioning the bound marks once identity is stable.