About

event.target vs currentTarget — The Bug That Only Fired on the Icon Stroke

Read data-* from currentTarget. SVG only takes clicks on painted strokes

If your code reads data-* from the element that owns the listener, use event.currentTarget. With event.target, the moment that element has a child, the result depends on which pixel got clicked. After this you won't mix the two up, and you'll avoid the trap where currentTarget turns into null inside async code.

The code that broke

Our bulk delete button looked like this.

<button data-action="click->bulk#delete" data-folder-uuid="abc123">
  <svg fill="none" stroke="currentColor"><path d="m14.74 9-.346 9…"/></svg>
  Delete
</button>

delete(event) {
  const folderUuid = event.target.dataset.folderUuid   // bug
  fetch(`/bulk/delete/new?folder_uuid=${folderUuid}`)
}

Click exactly on the stroke of the trash icon and event.target becomes the <path>. The <path> has no data-folder-uuid, so you get undefined. The server got called without a folder, looked up the selection under the wrong key, and showed a "Delete 0 items?" dialog.

Same button, different behavior depending on the pixel. It was hard to reproduce, so it lived for quite a while.

<button data-folder-uuid="abc123"> listener is here <svg fill="none"> Delete click ON the stroke target = <path> target.dataset → {} (bug) click BETWEEN strokes target = <svg> or <button> depends on browser and markup currentTarget = <button> always

What's the difference

event.target is the innermost element where the event actually happened. It stays the same while the event propagates. event.currentTarget is the element whose listener is running right now, and it changes at each step as the event bubbles up.

Put a listener on <button>, click the <path> inside, and target is <path> while currentTarget is <button>.

Half the confusion comes from mixing layers. event describes what happened. dataset and classList belong to DOM elements. event.dataset is undefined. target and currentTarget are just two pointers from the event to elements.

Why SVG icons, of all things

The default pointer-events for SVG shapes is visiblePainted: only painted parts take clicks. With a fill="none" icon, the hit area is a stroke about 1.5px wide. On the stroke you get <path>; in the gaps you get <svg> or <button>. Which one exactly depends on the browser and markup, so just log it.

document.querySelector('button').addEventListener('click', (e) => {
  console.log(e.target.tagName, e.currentTarget.tagName)
})

Click around the icon and only target changes between path, svg and BUTTON. currentTarget is always BUTTON.

Our <svg> also had a copy of data-folder-uuid. Someone had hit the same bug and patched one level deeper. That fixes most clicks, so you believe it's fixed. Clicks on the stroke still fall through, now rarer and harder to catch. Copying attributes downward was the wrong direction from the start.

The trap: currentTarget is null after the handler

target stays on the event object, but currentTarget resets to null once dispatch finishes. Read it after setTimeout or await and you get a TypeError.

async delete(event) {
  const button = event.currentTarget   // grab it while still synchronous
  await fetch('/x')
  console.log(event.currentTarget)     // null
  console.log(button.dataset.folderUuid)  // 'abc123'
}

Saving const button = event.currentTarget up front isn't style. It's about lifetime.

When target is right: event delegation

With one listener on a parent handling many children, currentTarget is always the parent, so it's useless. Start from target and walk up with closest().

list.addEventListener('click', (event) => {
  const row = event.target.closest('[data-contract-uuid]')
  if (!row || !list.contains(row)) return
  open(row.dataset.contractUuid)
})

The point is going through closest() rather than using target raw, because you hit the same "inner element got the click" problem here too.

You want to Use
Read data-* from the listener's element, disable the button event.currentTarget
Handle many children from one parent (delegation) event.target.closest('[data-x]')
Pass values in Stimulus data-bulk-folder-uuid-param → event.params.folderUuid

In Stimulus, the element with data-action is the currentTarget. event.params also converts types (numbers, booleans, JSON), so it's the cleanest choice for new code. If an existing codebase already uses data-folder-uuid everywhere, don't mix styles.

A button with an icon inside plus code reading target is almost certainly this bug. If you're chasing a front-end bug that only shows up sometimes, negabaro is happy to take a look with you.

Key Points

1

Read data-* of the listener element via event.currentTarget

2

SVG icons only take clicks on painted strokes, so target becomes <path>

3

Store currentTarget in a variable before await or setTimeout. It becomes null afterwards

4

With event delegation, walk up with target.closest()

Use Cases

Auditing click handlers on buttons that contain icons Delegated handlers that process row clicks from one parent
← Cookie, localStorage or sessionStorage — Three Questions Decide It Drums on setInterval Drift — Say "Play at 0.55s", Not "Play Now" →