JavaScriptでToDoリストや動的な一覧画面を作るとき、「最初からHTMLに書かれていたボタンはクリックできるのに、ボタン操作や通信で後から追加したボタンだけ何も反応しない」というトラブルに直面したことはないでしょうか。

// 最初からあるボタンには動くが、後から増やしたボタンは反応しない
document.querySelectorAll('.js-delete').forEach((button) => {
  button.addEventListener('click', () => {
    console.log('削除ボタンをクリック');
  });
});

同じクラス名を付けているのになぜ動かないのか。

その原因は、後から追加された要素には、先に実行したaddEventListener()によるイベント登録が行われていないからです。

この記事では、動的追加された要素にイベントが届かない仕組みから、新しく追加した要素だけにイベントを登録する方法、そして親要素でまとめてイベントを受け取る「イベントデリゲーション(イベント委譲)」の実装パターン(event.targetとclosest()の活用)までを解説します。

後から追加したボタンでclickが動かない例

まずは、多くの初級〜中級者がつまずく典型的なコードを見てみます。

<button type="button" class="js-add">項目を追加</button>

<ul class="js-list">
  <li>
    項目1
    <button type="button" class="js-delete">削除</button>
  </li>
</ul>
const addButton = document.querySelector('.js-add');
const list = document.querySelector('.js-list');
const deleteButtons = document.querySelectorAll('.js-delete');

// 1. ページ読み込み時に存在する .js-delete にイベントを設定
deleteButtons.forEach((button) => {
  button.addEventListener('click', () => {
    console.log('削除ボタンをクリック');
  });
});

// 2. 「追加」ボタンを押すと、新しい項目と削除ボタンがHTMLに挿入される
addButton.addEventListener('click', () => {
  list.insertAdjacentHTML(
    'beforeend',
    `
      <li>
        追加した項目
        <button type="button" class="js-delete">削除</button>
      </li>
    `
  );
});

このコードを実行すると、最初からHTMLにあった「項目1」の削除ボタンを押したときはコンソールに文字が出力されます。

しかし、「項目を追加」ボタンを押して新しく画面に増えた削除ボタンを押しても、コンソールには何も出力されません。

原因は後から追加した要素にイベントが登録されていないから

動かない理由は極めてシンプルです。

document.querySelectorAll('.js-delete')は、そのコードを実行した時点でDOM上に存在し、セレクタに一致する要素を取得します。

そしてaddEventListener()は、取得された個々のボタン要素に対して1つずつ処理を登録していきます。

【処理の時系列】
1. ページ読み込み
     ↓
2. querySelectorAll('.js-delete')
     ↓ (この時点でDOM上に存在していたボタンだけを取得)
3. addEventListener() を実行してイベント登録完了
     ↓
4. 「項目を追加」をクリックして新しいボタンをDOMへ挿入
     ↓ (過去に実行されたイベント登録処理は新しいボタンには適用されない)
5. 新しいボタンをクリックしても、イベントリスナーが未登録のため無反応

後からHTMLへ新しい要素を追加しても、以前実行されたaddEventListener()が自動的に新しい要素を検知してリスナーを生やしてくれるわけではありません。

※querySelectorAll()が返すNodeListは静的(static)であり、DOMの変更に合わせて自動更新されない仕様については、以下の記事でも詳しく解説しています。

querySelectorで要素を取得できない・nullになる原因と確認方法

解決方法1:追加したあとにaddEventListenerを登録する

1つ目の解決策は、「新しい要素を画面に追加した直後に、その新しく作られたボタンに対してaddEventListener()を呼ぶ」という方法です。

const addButton = document.querySelector('.js-add');
const list = document.querySelector('.js-list');
const deleteButtons = document.querySelectorAll('.js-delete');

function handleDelete(event) {
  console.log('削除:', event.currentTarget);
}

// 最初から存在する削除ボタンに登録
deleteButtons.forEach((button) => {
  button.addEventListener('click', handleDelete);
});

addButton.addEventListener('click', () => {
  // 新しい項目を追加
  list.insertAdjacentHTML(
    'beforeend',
    `
      <li>
        追加した項目
        <button type="button" class="js-delete">削除</button>
      </li>
    `
  );

  // 今追加した項目を取得
  const newItem = list.lastElementChild;
  const newDeleteButton = newItem.querySelector('.js-delete');

  // 新しく追加したボタンだけにイベントを登録
  newDeleteButton.addEventListener('click', handleDelete);
});

この方法でも問題なく動作します。

ただし、要素を追加するたびに「新しく追加した要素を取得して、イベントを登録する」という処理が必要です。

要素の追加・削除が頻繁に行われるUIや、Ajax・Fetch APIなどで一覧を部分的に更新する画面では、イベント登録の管理が複雑になりやすくなります。

そこで便利なのが、次の「イベントデリゲーション」です。

解決方法2:親要素にイベントを設定する(イベントデリゲーション)

イベントデリゲーション(Event Delegation:イベント委譲)とは、動的に増減する子要素ではなく、イベントを登録する時点ですでにDOM上に存在する親要素へイベントリスナーを設定し、子要素から伝わってきたイベントをまとめて処理する手法です。

// 増減する個別の .js-delete ではなく、親の .js-list に1つだけ登録する
list.addEventListener('click', (event) => {
  const deleteButton = event.target.closest('.js-delete');

  // クリックされた場所が .js-delete(またはその中身)でなければ何もしない
  if (!deleteButton) {
    return;
  }

  console.log('削除ボタンをクリック:', deleteButton);
});

このコードなら、後からボタンが何回追加されても、listの中に追加された.js-deleteであれば、追加するたびにaddEventListener()を登録し直す必要はありません。

なぜ親要素のaddEventListenerで子要素のクリックが分かるのか

「親要素にイベントを設定しているのに、なぜ子要素のクリックが検知できるのか」という疑問の答えは、ブラウザのイベントバブリング(Event Bubbling)という仕組みにあります。

HTMLの要素がクリックされると、そのイベントはクリックされた要素だけで完結するのではなく、親要素、親の親要素……とDOMツリーの上位に向かって順々に伝わっていきます。

【クリックイベントの伝播(バブリング)】
[ <button class="js-delete"> ] がクリックされる
            ↓ (イベントが親へ伝わる)
        [ <li> ]
            ↓ (イベントが親へ伝わる)
    [ <ul class="js-list"> ] ← ここで addEventListener が発火!

親要素である<ul>は、自分の内側にある子要素や孫要素で発生したクリックイベントを自然に受け取ることができます。

このバブリングの仕組みを応用したものがイベントデリゲーションです。

フォームで使うinput・change・blur・submitの発火タイミングや、blurのイベント伝播については、フォームイベントの記事で整理しています。

event.targetで実際にクリックされた要素を調べる

親要素でクリックを受け取ったとき、「どの子要素がクリックされたのか」を特定するために使うのが、イベントオブジェクトが持つevent.targetプロパティです。

list.addEventListener('click', (event) => {
  console.log('実際にクリックされた要素:', event.target);
});
  • event.target:クリックイベントが最初に発生した要素
  • event.currentTarget:現在イベントリスナーが実行されている要素(この例ではlist)

イベントデリゲーションでは、このevent.targetを調べることで「親要素のどこがクリックされたのか」を正確に判定します。

event.targetだけではうまくいかないことがある

ただし、実務のHTMLではevent.targetの単純な判定だけでは不十分なケースがあります。

例えば、ボタンの中にアイコン画像や<span>タグが入っている場合です。

<button type="button" class="js-delete">
  <span class="icon">🗑️</span>
  <span class="label">削除</span>
</button>

ユーザーがボタン内の文字やアイコン部分をクリックしたとき、ブラウザが判定するevent.targetは<button>ではなく、内側の<span>要素になります。

そのため、以下のような判定をしていると意図通りに動かないことがあります。

// ✕ うまくいかない例(spanをクリックしたときにスルーされてしまう)
list.addEventListener('click', (event) => {
  if (event.target.classList.contains('js-delete')) {
    console.log('削除ボタン');
  }
});

ボタンの外枠を押したときは動くのに、真ん中のアイコンを押したときは動かない、といった挙動の原因はここにあります。

closest()を使って対象のボタンを取得する

この問題を綺麗に解決するのが、DOM操作メソッドのclosest()です。

const targetElement = element.closest('セレクタ');

closest()は、自身を起点に親方向(先祖要素)へとDOMツリーを遡り、指定したCSSセレクタに一致する最も近い要素を1つ取得するメソッドです。

一致する要素が見つからなければnullを返します。

これを使うと、クリック判定が次のようにシンプルかつ堅牢になります。

list.addEventListener('click', (event) => {
  // クリックされた要素(event.target)から親方向に .js-delete を探す
  const deleteButton = event.target.closest('.js-delete');

  // .js-delete の外側(リストの背景など)をクリックした場合は中断
  if (!deleteButton) {
    return;
  }

  // <button> 自身を押しても、中の <span> を押しても、確実に <button> が取得できる
  console.log('削除対象のボタン:', deleteButton);
});

このように、対象が見つからなければ早い段階で処理を終了する書き方は、イベントデリゲーションでよく使われるパターンです。

イベントデリゲーションを使った完成コード

「項目の動的追加」と、後から追加された要素も含めた「項目の削除」を組み合わせた実践コードです。

<button type="button" class="js-add">項目を追加</button>

<ul class="js-list">
  <li class="item">
    既存の項目 1
    <button type="button" class="js-delete">
      <span>削除</span>
    </button>
  </li>
</ul>
const addButton = document.querySelector('.js-add');
const list = document.querySelector('.js-list');

// 1. 項目の追加処理
addButton.addEventListener('click', () => {
  const timestamp = Date.now().toString().slice(-4);
  list.insertAdjacentHTML(
    'beforeend',
    `
      <li class="item">
        追加された項目 (${timestamp})
        <button type="button" class="js-delete">
          <span>削除</span>
        </button>
      </li>
    `
  );
});

// 2. イベントデリゲーションによる削除処理
list.addEventListener('click', (event) => {
  // クリックされた位置から最も近い .js-delete を検索
  const deleteButton = event.target.closest('.js-delete');

  // 削除ボタン以外の場所をクリックした場合は処理を抜ける
  if (!deleteButton) {
    return;
  }

  // ボタンが所属している行要素(.item)を探して画面から削除
  const item = deleteButton.closest('.item');
  if (item) {
    item.remove();
  }
});

JavaScriptで要素を追加・削除・入れ替える基本的なDOM操作については、append・before・replaceWith・removeの使い方をまとめた記事で詳しく解説しています。

完成コードは下のCodePenでも確認できます。HTML、CSS、JavaScriptを切り替えながら、ボタンを押したときにどこが変わるのか見てみてください。

See the Pen Web制作JS|イベントデリゲーション|動的要素のclickサンプル by WebToolGen (@webtoolgen) on CodePen.

直接イベントを付ける方法とイベントデリゲーションの違い

要素へ個別に登録する方法と、イベントデリゲーションの比較です。

比較項目 要素へ個別に直接登録 イベントデリゲーション
イベントを付ける場所 各ボタン・各要素自身 共通の親要素
後から追加した要素 追加後にイベント登録が必要 親要素内の対象なら再登録不要
リスナーの登録数 対象要素ごとに登録 親要素側でまとめて登録できる
向いているケース 固定されたUI要素 リスト・テーブル・カードなど増減するUI

動的に増減する要素を扱う場合は、イベントデリゲーションの方がイベント登録を管理しやすいケースが多くあります。

イベントデリゲーションを使った方がよいケース

以下のような要件がある場合は、最初からイベントデリゲーションの採用を検討してください。

  • JavaScriptで項目を追加・削除するリストUI(ToDoアプリ、カート画面など)
  • AjaxやFetch APIでデータを取得し、一覧を後から再描画するテーブル
  • ユーザー操作でフォームの入力行が増えるUI
  • container.innerHTML = '...'で中身のHTMLを作り直すUI

※innerHTMLなどで子要素をまとめて置き換えると、それまで存在していた子要素はDOMから取り除かれ、新しい要素が作られます。

そのため、古い子要素に直接設定していたイベントリスナーは、新しく作られた要素には引き継がれません。

親要素自体を残してイベントを設定しておけば、このような更新にも対応しやすくなります。

参照:addEventListenerでclickイベントが動かない原因と確認方法

すべてdocumentにイベントを付ければよいわけではない

イベントデリゲーションの仕組みを覚えると、「親要素なら何でもいいなら、すべてdocumentに付けておけば確実なのでは?」と考えがちです。

// ✕ 不要に範囲が広すぎる書き方
document.addEventListener('click', (event) => {
  console.log(event.target);
});

documentに登録しても動作しますが、そのUIとは関係のないクリックまで同じリスナーで受け取ることになります。

対象範囲が必要以上に広くなると、判定条件が増え、別のUIとの関係も分かりにくくなります。

基本的には、対象の動的要素をまとめて囲っている「できるだけ近い親要素」をイベントの受け口にすると、コードの役割が明確になります。

イベントデリゲーションが動かないときの確認方法

イベントデリゲーションを書いたのにうまく動かない場合は、以下の順序で1つずつログを出力して切り分けます。

1. 親要素が正しく取得できているか

console.log('親要素:', list);

ここがnullになっているとイベントは登録されません。

セレクタやスクリプトの読み込み順を確認します。

2. 親要素までクリックが届いているか

list.addEventListener('click', (event) => {
  console.log('親要素でclickを検知');
});

親要素までログが届かない場合は、実際にクリックしている要素が想定どおりか、CSSで別の要素が上に重なっていないか、pointer-eventsの指定が影響していないかなども確認します。

3. event.targetに何が入っているか

console.log('クリックされた生要素:', event.target);

実際にクリックした箇所が取得できているかを確認します。

4. closest()のセレクタ指定が合っているか

console.log('判定結果:', event.target.closest('.js-delete'));

ここがnullになる場合、closest()に渡しているクラス名に.を忘れていないか、HTML側のクラス名とスペルが完全一致しているかを確認します。

まとめ

後から追加した要素でクリックが反応しないのは、JavaScriptの初歩から実務開発へステップアップする際によく遭遇する壁の1つです。

  • 原因の本質:querySelectorAll()などで取得した要素にaddEventListener()を設定しても、その後新しく作られた別の要素にはイベントリスナーは自動で登録されない
  • 解決策の基本:動的に増減する要素には、最初から存在する親要素でまとめて受ける「イベントデリゲーション」を使う
  • 仕組みの土台:子要素で起きたイベントが親へ伝わる「バブリング」を利用している
  • 判定の定石:event.target.closest('セレクタ')を使えば、ボタン内のアイコンや文字をクリックしても安全に取得できる

「要素を増やしたらイベントが動かなくなった」という場面に出合ったら、新しく要素を追加する処理を見直すとともに、親要素にリスナーを1つ置く設計を検討してみてください。