Some parts of D3 are naturally idempotent. For example, data joins using the General Update Pattern:
const points = d3.selectAll('.datapoint')
.data([ /* ... */]);
points.enter().append('div')
.attr('class', 'datapoint')
.text(d => d);
points.exit().remove();
If that code were wrapped in a reusable chart function, we could call it multiple times and get the same resulting DOM.
drawChart([ 'lonely div' ]);
drawChart([ 'lonely div' ]);
// RESULT ✅
// <div class="datapoint">lonely div</div>
Non-data-bound append operations, however, are not idempotent.
d3.select('body').append('svg');
This code will not render the same DOM when called multiple times.
drawChart();
drawChart();
// RESULT ❌
// <svg></svg><svg></svg>
For our chart components to be useful components, we have to make both data-bound and non-data-bound render idempotently.
For non-data-bound, we have to do our own DOM diffing to test whether an element has already been created.
let svg = d3.select('body').select('svg.my-chart');
if (svg.empty()) {
svg = d3.select('body').append('svg').attr('class', 'my-chart');
}
svg.selectAll('.datapoints')
.data([ /* ... */ ]);
// etc.
That works, but instead of writing this redundant pattern for every element, we can extend d3's selection function with a shortcut.
At POLITICO, we use a little function called appendSelect
:
/**
* appendSelect either selects a child of current selection or appends
* one to the selection if it doesn't exist. Useful for writing idempotent
* chart functions.
*
* Used like this:
*
* selection.appendSelect('div');
* selection.appendSelect('div', 'with-a-class');
* selection.appendSelect('div', 'one-class two-classes');
* selection.appendSelect('div').appendSelect('div', 'nested');
*
* @param {string} elementToAppend String representation of element to be appended/selected.
* @param {String} elementClass Class string (w/out dots) of element to be appended/
* selected. Can pass none or multiple separated by whitespace.
* @return {object} d3 selection of child element
*/
d3.selection.prototype.appendSelect = function (elementToAppend, elementClass) {
const selector = elementClass ?
`${elementToAppend}.${elementClass.split(' ').join('.')}` :
elementToAppend;
const selected = this.select(selector);
// If element doesn't already exist, append it...
if (selected.empty()) {
return elementClass ?
this.append(elementToAppend).classed(elementClass, true) :
this.append(elementToAppend);
}
// ... if it does, return it.
return selected;
};
Here's how we use it:
d3.select('#chart')
.appendSelect('h1', 'title')
.text('My chart');
// Can be chained to create nested elements
d3.select('#chart')
.appendSelect('svg')
.appendSelect('g', 'chart group');
// RESULT ✅
// <div id="chart">
// <h1>My chart</h1>
// <svg>
// <g class="chart group"></g>
// </svg>
// </div>