Skip to content

DOM manipulation with D3 in Vue 3

Showcase the evolution of D3's general update pattern.

5 min read

Prerequisites

Install the required packages:

pnpm add d3-selection d3-transition
pnpm add -D @types/d3-selection @types/d3-transition

How to import D3

You can import the whole of D3:

TypeScript
// Needs `pnpm add d3`
import * as d3 from 'd3'
// OR...
const d3 = await import('d3')

But the whole of D3 might contain functionality you won't be using at the moment.

I think it's better to install and import only the parts you know for sure you'll use.

For this tutorial we only need d3-selection and d3-transition.
Here's how you can build a d3 object with limited functionality:

TypeScript
import * as d3Selection from 'd3-selection'
import * as d3Transition from 'd3-transition'
const d3 = Object.assign({}, d3Selection, d3Transition)
// OR...
const d3 = await Promise.all([
import('d3-selection'),
import('d3-transition'),
]).then((d3s) => Object.assign({}, ...d3s))

The general update pattern

The original way to update the DOM with D3  was usually met with a lot of confusion by developers around the globe. 💫

Though it isn't really a difficult concept. It just requires a little bit of mind bending. 😁

Here, I'll try to distill the main idea using a simple example written as a Vue component.

With Vue you'll learn how to:

  • Reference a DOM node and pass it to D3

With D3 you'll learn how to:

  • Make the initial data binding
  • Update existing items
  • Append entering items
  • Remove exiting items

Here's the source code for the demo at the beginning:

HTML
<script setup lang="ts">
import * as d3Selection from 'd3-selection'
import * as d3Transition from 'd3-transition'
import { shuffle } from 'lodash-es'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const d3 = Object.assign({}, d3Selection, d3Transition)
type MaybeInterval = ReturnType<typeof setInterval> | null
const containerRef = ref<HTMLElement | null>(null)
const interval = ref<MaybeInterval>(null)
onMounted(() => {
render()
interval.value = setInterval(() => render(), 3000)
})
onBeforeUnmount(() => {
if (interval.value) {
clearInterval(interval.value)
}
})
function randomLetters() {
return shuffle('abcdefghijklmnopqrstuvwxyz'.split(''))
.slice(0, Math.floor(5 + Math.random() * 22))
.sort()
}
function render() {
const container = d3.select(containerRef.value)
const selection = container
.selectAll('.letter')
.data(randomLetters(), (d) => d as string)
// Update
selection.attr('class', 'letter text-stone-700')
// Enter
selection
.enter()
.append('div')
.attr('class', 'letter text-lime-600')
.style('opacity', 0)
.style('transform', 'translateY(-28px)')
.text((d) => d)
.transition()
.duration(1500)
.style('opacity', 1)
.style('transform', 'translateY(0px)')
// Exit
selection
.exit()
.attr('class', 'letter text-red-600')
.transition()
.duration(1500)
.style('opacity', 0)
.style('transform', 'translateY(28px)')
.remove()
}
</script>
<template>
<div ref="containerRef" class="flex h-7 flex-wrap gap-2"></div>
</template>

Introducing join

I just found out about join, and it seems to simplify working with D3's general update pattern by quite a lot.

Here's an example with auto appending, updating and removing:

JavaScript
const container = d3.select(containerRef.value)
container
.selectAll('.item')
.data(this.randomLetters(), d => d)
.join('em')
.text(d => d)
.attr('class', 'item mx-1')

If you need more control on what happens when entering, updating and removing you can have that too.

Here is the code rewritten in the join style:

HTML
<script setup lang="ts">
import * as d3Selection from 'd3-selection'
import * as d3Transition from 'd3-transition'
import { shuffle } from 'lodash-es'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const d3 = Object.assign({}, d3Selection, d3Transition)
type MaybeInterval = ReturnType<typeof setInterval> | null
const containerRef = ref<HTMLElement | null>(null)
const interval = ref<MaybeInterval>(null)
onMounted(() => {
render()
interval.value = setInterval(() => render(), 3000)
})
onBeforeUnmount(() => {
if (interval.value) {
clearInterval(interval.value)
}
})
function randomLetters() {
return shuffle('abcdefghijklmnopqrstuvwxyz'.split(''))
.slice(0, Math.floor(5 + Math.random() * 22))
.sort()
}
function render() {
const container = d3.select<d3Selection.BaseType, unknown>(containerRef.value)
const t = container.transition().duration(1500)
container
.selectAll('.letter')
.data(randomLetters(), (d) => d as string)
.join(
(enter) =>
enter
.append('div')
.attr('class', 'letter text-lime-600')
.style('opacity', 0)
.style('transform', 'translateY(-28px)')
.text((d) => d)
.call((enter) =>
enter
.transition(t)
.style('opacity', 1)
.style('transform', 'translateY(0px)')
),
(update) => update.attr('class', 'letter text-stone-700'),
(exit) =>
exit.call((exit) =>
exit
.attr('class', 'letter text-red-600')
.transition(t)
.style('opacity', 0)
.style('transform', 'translateY(28px)')
.remove()
)
)
}
</script>
<template>
<div ref="containerRef" class="flex h-7 flex-wrap gap-2"></div>
</template>

This doesn't necessarily mean the general update pattern is deprecated or anything.

It just means we now have another ---and most probably, better--- way to do the same thing. 😃

Resources

More notes connected by topic, not algorithmic noise.