The data join replaces hand-written loops. selectAll('rect') selects the rectangles that exist (none, the first time), data(array) pairs them with the items, and join('rect') appends a rectangle for every item without one and removes every rectangle without an item. It returns all the matched elements, so the calls that follow run once per datum:
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<svg width="290" height="108"></svg>
<code id="log" style="vertical-align:top; margin-left:16px; font-size:15px"></code>
<script>
const pages = [312, 428, 256, 198, 344, 176]; // BookNest's page counts
const rects = d3.select('svg').selectAll('rect').data(pages).join('rect')
.attr('x', (d, i) => i * 48).attr('width', 40)
.attr('y', d => 108 - d / 4).attr('height', d => d / 4).attr('fill', '#5b3f99');
d3.select('#log').text(`2nd rect's __data__: ${rects.nodes()[1].__data__}`);
</script>
D3 18,089 stores each datum on its element as __data__, so later selections of those elements still know their data. For one element that holds a whole dataset, such as a line chart's single <path>, datum(value) sets it without a join (Lines and Curves).