DOM イベント、伝播、イベント委譲

イベントの捕捉・バブリング、default の制御、動的な UI で安全にイベントを扱う設計を学ぶ。

DOM イベントは、クリックした要素だけで完結する単純な通知ではありません。イベントは DOM ツリーを通って移動し、親にも処理の機会を与えます。この仕組みを理解すると、動的な一覧を簡潔に扱えるイベント委譲と、stopPropagation() を多用しない設計が可能になります。

イベントは三つの段階を通る

あるボタンをクリックすると、イベントは概念的に次の順で移動します。

window → document → ... → 親要素 → ボタン  (capture)
ボタン                                      (target)
ボタン → 親要素 → ... → document → window  (bubble)

通常の addEventListener('click', handler) はバブリング段階で実行されます。第 3 引数またはオプションで { capture: true } を指定すると、対象へ届く前の捕捉段階で受け取ります。親のハンドラでも、実際に操作された最も内側の要素は event.target、ハンドラを登録した要素は event.currentTarget です。

list.addEventListener('click', (event) => {
  console.log(event.target); // クリックされた icon や button
  console.log(event.currentTarget); // 常に list
});

子のボタン内の svg をクリックすると event.target は svg になり得ます。対象のボタンを判定したいなら、closest() で意図した祖先までたどり、対象のコンテナ内にあることも確認します。

イベント委譲で動的な子を扱う

多数のリスト項目それぞれにリスナーを付ける代わりに、安定している親要素に一つだけリスナーを付ける方法をイベント委譲と呼びます。後から追加した子にもバブリングで対応できる点が特に有用です。

todoList.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="toggle"]');
  if (!button || !todoList.contains(button)) return;

  const item = button.closest('[data-todo-id]');
  if (!item) return;
  toggleTodo(item.dataset.todoId);
});

この例では、各行が追加・削除されてもリスナーを再登録する必要がありません。data-* 属性は、表示用の class と異なり、イベント処理に必要な識別子や操作種別を明示できます。

ただし、すべてを document レベルで委譲する必要はありません。機能のルートに近い、ライフサイクルが分かる要素へ登録すると、別の機能のイベントを誤って処理しにくくなります。イベントを止める必要がある構造は、まず責務の範囲を狭めて解決できないか考えます。

preventDefault と stopPropagation は役割が違う

preventDefault() はブラウザの既定動作を止めます。フォーム送信を JavaScript で扱う、リンク遷移を確認後に行う、といった場合に使います。

form.addEventListener('submit', (event) => {
  event.preventDefault();
  save(new FormData(form));
});

stopPropagation() は親への伝播を止めます。目的が全く異なるため、「クリックを処理したら両方呼ぶ」という定型句にしないでください。たとえばリンクの既定遷移を止めても、親のクリックハンドラにはイベントが届きます。

stopPropagation() が各部品に散らばると、親が観測すべきイベントまで届かず、機能を組み合わせにくくなります。本当に独立した操作領域であるか、親が同じイベントを別の意味で使っていないかを確認してから使います。

非同期処理と二重送信を防ぐ

クリックや submit は短時間に複数回起こせます。通信を始める操作では、DOM を直接読むだけでなく、isSubmitting のような状態を正本として二重実行を防ぎます。

let isSubmitting = false;

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  if (isSubmitting) return;

  isSubmitting = true;
  submitButton.disabled = true;
  try {
    await save(new FormData(form));
  } finally {
    isSubmitting = false;
    submitButton.disabled = false;
  }
});

finally で UI を戻すことで、成功と失敗のどちらでも次の操作が可能になります。画面を離れた後の結果を反映しない、古いリクエストを中止する、といった問題は AbortController も含めて個別に設計します。

フレームワークのイベントとの関係

React、Vue、Angular はテンプレートや JSX の記法でイベントを宣言できますが、最終的にはブラウザの DOM イベントを利用します。フレームワークは state 更新と再レンダーを結び付け、コンポーネントの破棄時に登録を管理しやすくします。

ネイティブの addEventListener が必要なのは、window、document、ResizeObserver などの外部 API とつなぐときです。その場合は React の Effect や Vue の onMounted / onUnmounted のようなライフサイクルに登録と解除を対にして置きます。

まとめ

イベントは捕捉、対象、バブリングを通ります。target と currentTarget を区別し、安定した親へのイベント委譲を使うと、動的な UI を小さな責務で扱えます。既定動作の停止と伝播の停止を混同せず、非同期処理では UI 状態も含めて設計しましょう。

Sources