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.
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
Read data-* of the listener element via event.currentTarget
SVG icons only take clicks on painted strokes, so target becomes <path>
Store currentTarget in a variable before await or setTimeout. It becomes null afterwards
With event delegation, walk up with target.closest()