About

event.target vs currentTarget — アイコンの線の上を押した時だけ起きるバグ

data-* を読むならcurrentTarget。SVGは塗られた線だけがクリックを受ける

リスナーを付けた要素の data-* を読むコードなら event.currentTarget を使う。event.target だと、その要素に子要素がひとつでもあった瞬間、クリックしたピクセル次第で結果が変わる。読み終えればこの二つを取り違えなくなり、非同期の中で currentTarget が null になる罠も避けられる。

実際に壊れたコード

一括削除ボタンはこうなっていた。

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

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

ゴミ箱アイコンの線の上をちょうど押すと、event.target が <path> になる。<path> には data-folder-uuid がないので undefined。サーバーはフォルダ指定なしで呼ばれ、見当違いのキーで選択状態を探し、「0件を削除しますか?」というモーダルを出した。

同じボタンなのに、押したピクセルで動きが分かれた。再現しにくくて、けっこう長く生き残ったバグだ。

<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

二つは何が違うのか

event.target はイベントが実際に起きた一番内側の要素。伝播の間ずっと変わらない。event.currentTarget は今実行中のリスナーが付いている要素で、バブリングで上がるたびに変わる。

<button> にリスナーを付けて中の <path> を押すと、target は <path>、currentTarget は <button> になる。

混乱の半分は層が混ざることから来る。event は「何が起きたか」を持つオブジェクトで、dataset や classList はDOM要素側のプロパティだ。event.dataset は undefined。target と currentTarget は、イベントが持っている要素へのポインタ二本にすぎない。

なぜSVGアイコンで起きるのか

SVG図形の pointer-events の既定値は visiblePainted で、実際に塗られた部分だけがクリックを受ける。fill="none" のアイコンなら、当たり判定は太さ1.5px程度の線だけだ。線の上なら <path>、線の間なら <svg> か <button> になる。どこが何になるかはブラウザとマークアップで違うので、ログを出して見るのが早い。

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

アイコンのあちこちを押すと、target だけが path・svg・BUTTON と変わり、currentTarget は常に BUTTON だ。

うちのコードでは <svg> にも data-folder-uuid がコピーされていた。誰かが同じバグに出会って、一段だけ深く貼った跡だ。これでたいていのクリックは直るので「直った」と思い込む。線の上のクリックは相変わらず抜けて、確率が下がった分だけ捕まえにくくなっただけだった。属性をコピーする方向がそもそも間違っている。

罠 — currentTargetはハンドラが終わるとnull

target はイベントオブジェクトに残るが、currentTarget はディスパッチが終わると null に戻る。setTimeout や await の後で読むと TypeError になる。

async delete(event) {
  const button = event.currentTarget   // 同期のうちに掴んでおく
  await fetch('/x')
  console.log(event.currentTarget)     // null
  console.log(button.dataset.folderUuid)  // 'abc123'
}

const button = event.currentTarget と先に受けておくのは好みの問題ではなく、寿命の管理だ。

逆にtargetが正しい場合 — イベント委譲

親ひとつにリスナーを付けて子をまとめて扱うときは、currentTarget が常に親なので役に立たない。target から始めて closest() で上にたどる。

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

target をそのまま使わず closest() を通すのがポイントだ。「内側の要素が取れてしまう」問題にここでも同じように出会うからだ。

やりたいこと 使うもの
リスナーを付けた要素のdata-*を読む、ボタンをdisabledにする event.currentTarget
親ひとつで子を複数扱う(委譲) event.target.closest('[data-x]')
Stimulusで値を渡す data-bulk-folder-uuid-param → event.params.folderUuid

Stimulusでは data-action が付いた要素がそのまま currentTarget だ。event.params は数値・真偽値・JSONへの型変換までしてくれるので、新しいコードならこれが一番すっきりする。既存コードが data-folder-uuid 式で揃っているなら、わざわざ混ぜない。

ボタンの中にアイコンがあって target で読んでいるコードを見かけたら、ほぼ確実に同じバグだ。こういう「たまにしか再現しない」フロントのバグを追っているなら、negabaroに一緒に見てもらうのも手だ。

キーポイント

1

リスナーを付けた要素のdata-*はevent.currentTargetで読む

2

SVGアイコンは塗られた線だけがクリックを受けるのでtargetが<path>になる

3

await・setTimeoutの前にcurrentTargetを変数に取っておく。終わるとnullになる

4

イベント委譲ではtarget.closest()で上にたどる

ユースケース

アイコン入りボタンのクリックハンドラの点検 一覧の行クリックを親ひとつで処理する委譲コード
← Cookie・localStorage・sessionStorage — 三つの質問で決まる setIntervalでドラムを鳴らすとリズムが揺れる — 「今鳴らせ」ではなく「何秒に鳴らせ」 →