Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~21 min· topic 6 of 7

Topic 5.6

Events in D3

In one line

selection.on("click", (event, d) => ...) attaches a listener to every selected mark, and gives it the browser's event and the mark's datum. Learn the version 7 form, how to remove listeners, why re-running draw must not stack them, and when one listener on the parent beats one per mark.

Think of it like this

A restaurant with 25 tables, each with a call bell. Fitting a bell to each table works, but whenever a table is added someone must fit a new bell. A single waiter at the door who watches for any raised hand and notes which table it came from does the same job with one person. The first is a listener per mark; the second is event delegation.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

event
A message from the browser that something happened: a click, a key press, a pointer move.
listener
A function you attach to an element that runs when a given event reaches it.
event object
The details of one event: its type, the element it hit (target), the pointer position.
namespace
A label after a dot, such as click.log, that lets D3 keep several listeners for one event on one element.
event delegation
Listening on a parent for events that come from its children, and checking which child it was.
bubbling
An event travelling from the element it hit up through its parents, so a parent's listener can hear it.

Step by step

01Attach a click to every bar

The first example makes three bars with a click listener that gets (event, d). The script clicks Tuesday's bar with dispatchEvent. The Console shows the datum (Tue), the event's type, that event.target === this and the tag of this, rect.

02Change the datum, click again, then remove the listener

The example sets a new datum on Tuesday's bar, and the next click reports Tue*, the new value. Then on("click", null) removes the listeners: a click on Monday's bar changes nothing, and on("click") returns undefined.

03Count the listeners

In the second example draw runs three times and adds two kinds of listener. One click then ran the D3 one once and the raw addEventListener one three times. Delegation: one listener on the svg served all 40 bars, ignored a click on the background, and two namespaced listeners (click.a, click.b) both ran until click.a was removed.

04Read the pointer position

The third example dispatches a pointermove and reads d3.pointer(event): the position inside the SVG, in the SVG's own units.

Read the pointer positiondiagram
Rendering diagram…

Try it yourself

  1. 1

    Click Wednesday

    In the first example change click(bars[1]) (the first call) to click(bars[2]). The Console prints click on Wed and the message becomes Wed: 3759 rides.

  2. 2

    Use a namespace to keep both

    In the first example add .on("click.second", () => console.log("second")) after the first .on(...) call. One click now prints both the first listener's line and second, because the names differ. Write .on("click", () => console.log("second")) instead and the first listener is replaced.

Code & diagrams

Click, datum, and removing a listener New tab

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

click on Tue | event.type: click | target is this: true | this: rect
message: Tue: 3992 rides
click on Tue* | event.type: click | target is this: true | this: rect
after on('click', null) the message is still: Tue*: 1 rides
listener getter: undefined
Double-binding, delegation and namespaces New tab

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

after 3 draws and one click: d3 handler ran 1 time, raw handler ran 3 times
delegated click on S13
delegated handler ran 1 time (the background click was ignored)
a
b
b
Pointer coordinates New tab

`d3.pointer` subtracts the SVG's position on the page, so the numbers are the same wherever the chart sits.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

pointer at 100 50
pointer at 5 115

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

The old d3.event

In the first example replace the handler with .on("click", function (d) { console.log(d3.event.target.tagName); }), the version 5 form.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot read properties of undefined (reading 'target')

Myth vs fact

Myth

I have to re-attach listeners after every update.

Fact

Not for elements that stay: the listener stays and reads the element's current datum. You only attach to elements the join creates.

Myth

on and addEventListener do the same thing.

Fact

on replaces a listener of the same name on that element. addEventListener always adds another, which is how handlers run three times after three updates.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    D3 stores listeners by type and name in a property on the element (__on) and registers one real browser listener per type and name. Replacing with the same name removes the old one first. That is why on is idempotent.

  • ▸

    on("click", fn, { passive: true }) passes options to addEventListener. Scroll-blocking events such as touchmove and wheel listeners should be passive, or the page's scrolling waits for your code (Topic 8.5).

Remember this

  1. 1

    The problem this solves: a chart that cannot be clicked or pointed at is a picture. Events connect what the learner does (a click, a pointer moving across a bar) to what the code knows about that mark (its datum). D3 hands you both in one function.

  2. 2

    The version 7 form. selection.on("click", (event, d) => { ... }). The first argument is the browser's real event object (event.type, event.target, event.clientX). The second is the datum bound to the element that received it. Inside a function () {}, this is that element. Old code (version 5 and earlier) wrote function (d) { d3.event... }: d3.event is gone, and reading it throws TypeError: Cannot read properties of undefined. The breakIt shows the error text.

  3. 3

    The datum is read when the event happens. If you update the data between the draw and the click, the listener sees the new datum, because D3 reads the element's current __data__ each time. You do not need to re-attach listeners after an update, only after a new element is created.

  4. 4

    One name, one listener. on("click", fn) replaces an earlier click listener that D3 attached to the same element. So calling it again on every draw is safe. Add a namespace ("click.highlight", "click.log") to keep several independent listeners for the same event. Remove one with on("click", null) or on("click.log", null). Reading on("click") with no listener returns undefined.

  5. 5

    Double-binding. element.addEventListener("click", fn) is different: it adds another listener every time. If a draw function calls it for every bar on every update, then after three updates one click runs the handler three times. The second example shows d3.on giving 1 and addEventListener giving 3. Use on, or attach once when the element is created.

  6. 6

    Pointer events and coordinates. Use pointerenter, pointermove, pointerleave and pointerdown instead of mouse...: one set of events covers a mouse, a pen and a finger (Topic 8.1 goes deeper). d3.pointer(event) returns [x, y] in the coordinates of the event's currentTarget element, such as the SVG, so it is already in the same units as your drawing.

  7. 7

    Delegation. For many marks (hundreds), one listener on the parent is lighter than one per mark: svg.on("click", (event) => { const bar = event.target.closest("rect"); if (!bar) return; ... d3.select(bar).datum() }). A click on the empty background has no rect ancestor, so the handler returns early. Delegation also means new bars need no new listener.

  8. 8

    Failure cases. A click on a text label inside a g has event.target set to the text, not the rect: closest finds the right ancestor. A handler that throws is reported as an uncaught error in the console and the page keeps running. A thin line is hard to hit: give it an invisible wide partner (Topic 8.1). A touch screen has no hover, so never hide information behind pointerenter alone.

Explain it without notes

01

What arguments does a D3 7 listener receive?

02

Why does a listener added with addEventListener run three times after three draw calls, but one added with on run once?

03

When is event delegation better than a listener per bar?

Practice

01

Write a draw(rows) that makes bars and a click listener that writes the station name into #name. Make sure calling it three times leaves one listener per bar.

02

Rewrite it with delegation: one listener on the svg. What changes when a click lands on the background?

Trade-offs

  • ↔

    A listener per mark is simple to read and costs memory per element. Delegation is lighter and needs closest logic.

Done when you can

  • I can attach a click with (event, d) and read the datum.

  • I know why d3.event fails in D3 7.

  • I can remove a listener and use namespaces for several.

  • I can explain double-binding and how on avoids it.