こんにちは!Kredo Blog編集部のさおりです!
最初からあるボタンは動くのに、JavaScriptで追加したボタンだけ反応しない。直したつもりが、今度はボタンの文字を押したときだけ動かない——クリック処理を書き始めると、こんな違いに出会います。
まず分けたいのは、処理をどこに登録したか、クリックがどこで起きたか、どの要素を処理対象と判断したかの3点です。イベント委譲は、このうち登録先を親要素にまとめ、子要素で起きたイベントを受け取る方法です。
この記事では、完了ボタンが並ぶ小さな一覧で3方式を比較します。追加・文字部分のクリック・伝播停止を試せる教材と練習4問を用意しました。通常のHTML内のclickを対象に、MDNの仕様説明とChromeでの実行結果を照らし合わせて進めましょう。
イベント委譲は、親で子のクリックを受け取る方法

ボタンの中に文字用のspan、その外側に一覧のulがあるとします。spanをクリックしたとき、clickイベントはボタンを経由して親のulへ伝わります。このように内側から祖先へ伝わる段階をバブリングと呼びます。
イベント委譲では、この性質を使ってulにクリック処理を登録します。親で受け取った後に「完了ボタンの操作だったか」を判定し、該当する場合だけ行の表示を変えます。親に登録しただけでは、すべてのクリックを完了操作として扱うことになりかねません。受け取る場所と、処理する対象を選ぶ条件はセットです。
図では文字→ボタン→親の一覧までを描いています。実際の経路はそこで必ず終わるわけではありません。この記事は、一覧に登録した処理まで届くかに注目します。基本の説明はMDNのイベントバブリングと委譲で確認できます。
クリック処理自体が初めてなら、先にJavaScriptサンプルコード集のボタンやFAQを動かしてみてください。ここでは、その次の「要素が増えたとき、登録方法をどう考えるか」に進みます。
追加したボタンに処理が付いていない理由

次のコードは、実行した時点で一覧にある完了ボタンを取得し、それぞれに処理を登録します。教材の「1. 最初のボタンに個別登録」は、この方法です。completeは、渡されたボタンが属する行を完了済みにする教材内の関数です。
list.querySelectorAll('button[data-action="complete"]').forEach(button => {
button.addEventListener('click', event => {
complete(event.currentTarget, event);
});
});
querySelectorAll()が返すNodeListは、その時点の結果を持つ静的な一覧です。後からボタンを作っても、この一覧に自動追加されて処理が登録されるわけではありません。教材は違いを見るため、追加時の登録を意図的に省いています。MDN:querySelectorAll()
個別登録でも、作るたびに登録すれば動く
修正方法はイベント委譲だけではありません。新しいボタンを作った直後に、そのボタンへaddEventListenerで登録する方法もあります。少数の独立したボタンなら、個別登録のほうが処理の所在を読み取りやすいこともあります。
Kredo Blogの学習記録アプリの教材は、一覧を描画するときに編集・削除ボタンへ処理を登録する構成です。「個別登録だから追加要素は動かない」と決めつけず、作成時に登録しているかをコードで確かめましょう。
一方、同じ種類の項目を繰り返し追加する一覧では、残り続ける親に処理を置くと、追加のたびに完了処理を登録する手順をまとめられます。ただし、親のul自体を別の要素へ置き換えたら、古いulの登録が新しいulへ移るわけではありません。子を追加する場合と、親を作り直す場合は分けて考えてください。
targetとcurrentTargetを押す場所で比べる

見た目では同じ「完了」ボタンでも、内側のspanをクリックすると、イベントの発生元はspanになります。今回の教材では、文字を直接クリックしたときのtargetはSPAN、ボタンをキーボードのEnterで操作したときはBUTTONになりました。
targetはイベントの発生元、currentTargetは今実行しているハンドラーが登録された要素です。親に処理を登録すると、ボタン内の文字を押してもcurrentTargetはULです。MDN:target、MDN:currentTarget
| 操作と登録方法 | target | 完了処理のcurrentTarget |
|---|---|---|
| 文字をクリック・ボタンに個別登録 | SPAN | BUTTON |
| 文字をクリック・親でclosest | SPAN | UL |
| Enterで操作・親でclosest | BUTTON | UL |
表が画面に収まらない場合は、横にスクロールできます。
target自身がボタンかどうかだけでは足りない
教材の2番目の方式は、親に登録しながら、target自身が完了ボタンかだけを調べます。
list.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
if (!event.target.matches('button[data-action="complete"]')) return;
complete(event.target, event);
});
Enter操作ではtargetがBUTTONなので通ります。しかし文字部分をクリックするとSPANのため、条件に合わずreturnします。親に届いていないのではなく、届いた後の条件判定で除外している点が重要です。教材のlistReceivedがtrueでも、完了処理の回数は増えません。
なお、currentTargetを調べるのはハンドラー実行中です。後からイベントオブジェクトを見直すとnullになることがあります。教材では、その場で要素名を文字列として記録してから画面に表示しています。
closestでボタンを探し、対象を絞る
文字部分からボタンを探すには、closest()を使います。これは自分自身から祖先へ向かって条件に合う最も近い要素を探し、見つからなければnullを返します。子要素を探すメソッドではありません。MDN:closest()
HTMLとJavaScriptを対応させる
最小例では、行をli、完了操作をdata-action=”complete”、状態の表示を.stateで示します。以下のHTMLとJavaScriptは、教材のminimal.html / minimal.jsに対応しています。JavaScriptはこのHTMLが読み込まれた後に実行してください。教材ではdefer属性で読み込んでいます。
<ul id="simple-list">
<li>
<button type="button" data-action="complete"><span>完了</span></button>
<span class="state">未完了</span>
</li>
</ul>
const list = document.querySelector('#simple-list');
list.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-action="complete"]');
if (!button || !list.contains(button)) return;
const row = button.closest('li');
if (!row || row.parentElement !== list) return;
row.querySelector('.state').textContent = '完了済み';
});
処理は、発生元がElementか確認→最も近い完了ボタンを取得→一覧内のボタンか確認→直下のliか確認→その行の表示を更新、の順です。nullのまま次の操作へ進まないよう、見つからない場合は早めにreturnします。
closest('button[data-action="complete"]')なら、同じ一覧にある「別の操作」のボタンを区別できます。containsは、見つけたボタンがこの一覧の内側にあることを確かめる条件です。さらにこの例は、対象のliが一覧の直下であることも確認しています。入れ子の別一覧まで操作したくないためです。
このコードは、各liに.stateがあるという上のHTMLを前提にしています。別のHTMLへ移すなら、クラス名・親子関係・data-actionを合わせてください。見た目が似ていても、要素の構造が違えば同じ条件で動くとは限りません。
3方式をローカル教材で動かして比べる
ZIPには比較画面、本文の最小例、24ケースの期待値、練習と解答、記録用CSVを入れています。アカウント登録や追加パッケージは不要です。Node.jsが使える環境で、展開したフォルダーをターミナルで開き、次のコマンドを実行してください。
node server.mjs 8775
ブラウザでhttp://127.0.0.1:8775/を開きます。終了はターミナルでCtrl+Cです。同じポートが使われていたら、8776など別の番号で起動し、URLの番号も合わせます。JavaScriptモジュールを使うため、HTMLのダブルクリックではなくローカルHTTPで開いてください。環境の準備はJavaScriptの開発環境と実行方法を参照できます。
毎回リセットして、1つずつ条件を変える
- 「比較する方式」で1・2・3のどれかを選ぶ。方式を変えると初期化されます。
- 最初のボタンの文字「完了」をクリックし、targetと完了処理の回数を見る。
- リセットして「項目を1つ追加」を押し、新しい項目の文字をクリックする。
- リセット後、Tabでボタンを選んでEnterを押す。文字クリックとの違いを見る。
- 最後に伝播停止をオンにし、親まで届くかを比べる。
以下は2026年10月6日にmacOSのChromeで確認した結果です。各行はリセット後の独立した操作で、伝播停止は最後の行だけオンです。「動く」は完了処理が1回実行されることを表します。
| 操作 | 最初に個別登録 | 親でtargetのみ | 親でclosest |
|---|---|---|---|
| 最初のボタンをEnter | 動く | 動く | 動く |
| 最初のボタンの文字 | 動く | 動かない | 動く |
| 追加したボタンをEnter | 動かない | 動く | 動く |
| 追加したボタンの文字 | 動かない | 動かない | 動く |
| 「別の操作」をEnter | 対象外 | 対象外 | 対象外 |
| 行の「項目1」をクリック | 対象外 | 対象外 | 対象外 |
| 一覧外のボタンをEnter | 対象外 | 対象外 | 対象外 |
| 伝播停止+最初のボタンをEnter | 動く | 動かない | 動かない |
表が画面に収まらない場合は、横にスクロールできます。
3方式×8操作の24ケースを確認しています。別ブラウザや実機スマートフォンの検証結果ではありません。また、教材はページ内の観察にもリスナーを使っています。表示される回数は完了処理の実行回数であり、リスナー総数や速度の測定値ではありません。完了済みのボタンをもう一度操作しても、処理回数は増えます。
listReceivedがfalseなら親までの伝播、trueなのに処理が0回なら対象判定や登録方法、という順に確認すると、原因を絞れます。actionCurrentTargetがnullなのは、この教材で完了処理が動かなかったという意味です。
練習:追加・子要素・伝播停止を確かめる
練習1:同じボタンでも、押す場所で変わるか
3方式それぞれで、最初のボタンの文字をクリックしてください。次にリセットし、TabとEnterで操作します。targetがどう変わり、どの方式だけ結果が変わるかを記録しましょう。
解答のポイント:targetのみを判定する方式ではSPANを除外します。closestを使う方式ではSPANからBUTTONを探せるため、どちらの操作でも完了処理が動きます。
練習2:追加したボタンを動かす方法を2つ挙げる
「最初のボタンに個別登録」で項目を追加し、反応しないことを確かめてください。イベント委譲以外の直し方も考えます。
解答のポイント:新しいボタンを作るたびに個別登録する方法と、残る親に登録して対象を絞る方法があります。すでにあるボタンまで無条件に繰り返し登録すると、別の関数を重ねて登録して処理が複数回動く原因になるため、登録のタイミングを決めておきます。
練習3:子で伝播を止めるとどうなるか
「親でclosest」を選び、伝播停止をオンにして最初のボタンをEnterで操作します。listReceivedと処理回数を見た後、オフに戻して同じボタンを試してください。
解答のポイント:オンでは子ボタンのstopPropagation()によって親のバブリング段階へ届かず、オフでは届きます。closestの条件を変えても、そもそも親の処理が呼ばれない状態は直りません。
練習4:完了させない操作を確認する
「親でclosest」のまま「別の操作」、行の項目名、一覧外のボタンを操作してください。完了処理が増えない理由を、それぞれ説明してみましょう。
解答のポイント:別の操作はdata-actionが違い、項目名からは該当ボタンが見つからず、一覧外のボタンは一覧を通りません。動く操作に加えて、処理しない操作も確認することで条件の抜けを見つけられます。確認項目の増やし方はアプリのテスト方法とチェックリストでも紹介しています。
イベント委譲でよくある質問
stopPropagationとpreventDefaultは同じですか?
異なります。stopPropagationはイベントがさらに伝わることを止めますが、リンク移動などの既定動作をそれだけで取り消すものではありません。また、同じ要素に登録された別のハンドラーまで止めるものでもありません。そのため教材の個別登録では、子ボタンで伝播を止めても、同じボタンにある完了処理は動きます。MDN:stopPropagation()
すべてのイベントを親に登録できますか?
この記事の方法は、通常のHTML内でバブリングするclickを前提にしています。別のイベントを扱うときは、そのイベントがバブリングするかを先に確認してください。Shadow DOMやiframeをまたぐ処理は、この教材の範囲に含めていません。
documentにまとめればよいですか?
まずは、その操作を受け持つ近い親を選びましょう。今回なら一覧のulです。対象外の画面領域まで広げると、どのクリックを受け取り、どこまで判定するかが読みにくくなります。親が再描画で置き換わらないかも合わせて確認します。
イベント委譲は必ず速くなりますか?
この教材では速度やメモリを測っていません。まずは、同じ処理をどこで登録し、追加時に何をする必要があるかで選んでください。固定された少数のボタンと、動的に増える一覧では、読みやすい構成が変わります。
次に自分のコードでボタンが動かなかったら、登録先→親への到達→target→対象条件の順に見直してみてください。入力内容の正しさを確かめる処理は別の役割なので、続けて学ぶならフォームの入力チェック教材へ進めます。
英語でIT・AIを学べるKredo
英語×IT・AIのスキルを身につけてグローバルに活躍しませんか?
当メディアを運営しているKredoは、英語×ITをオンラインで学ぶ「Kredoオンラインキャンプ」と、フィリピンのセブ島で英語とIT・AIを学ぶ「KredoIT留学」「KredoAI留学」を提供しています。これまでの卒業生は3,000名以上。卒業生の多くが、国内外のIT企業への転職、フリーランスなどへのキャリアチェンジを実現しています。これからの時代に必要な英語×IT・AIのスキルを身につけてグローバルに活躍しませんか?









