はじめに
Reactでのイベントの特徴としてイベントデリゲーションを使っている点がありますが、この理解をするために手を動かしながら整理しました。
イベントデリゲーションについて
ReactではonClick
などで各要素にイベントリスナを設定することができますが、実際はその対象のDOMノードにイベントハンドラを設定していません。ReactはそれをReactルート要素にイベントデリゲーションして内部的にイベントの処理をしているようです。
この処理はReact 17で一部変更があった部分で、React 17のリリースブログに画像つきで解説されています。(イベントデリゲーションに関する変更)

ブログで紹介されているこちらの記事によると、要素が追加・削除されるごとに各要素のイベントリスナを設定し直す必要がなくなるというメリットがあるようです。
DOMイベントフローについて
もう一つイベントの処理で知っておく必要があるのは、DOMイベントフローについてです。
クリックなどのイベントが発火すると、キャプチャリング、バブリングフェーズというフェーズに分かれてイベントが伝播します。
W3Cの3.1. Event dispatch and DOM event flowにわかりやすい画像があるので、そちらを引用します。

ReactでonClick
などでイベントリスナを設定するのはバブリングフェーズで、キャプチャリングフェーズに設定する場合はonClickCapture
などを使用します。
addEventListener
でキャプチャリングフェーズにイベントリスナを設定するには、第3引数に{ capture: true }
を設定します。
これ以降はこの2つの図をイメージしながら読んでもらえたらと思います。
2つの図
3.1. Event dispatch and DOM event flow | イベントデリゲーションに関する変更 |
---|---|
![]() |
![]() |
実装
準備
今回は以下のようなdiv2つに囲まれたbuttonをクリックするときのイベントの挙動を見てみます。
export const Event = () => {
return (
<div id='grandParendDiv'>
<div id='parendDiv'>
<button id='currentButton'>
Click here
</button>
</div>
</div>
)
}
各要素のキャプチャリングフェーズ、バブリングフェーズのイベントリスナにログを出力するコールバックをイベントハンドラとして設定していきます。
import { MouseEvent } from 'react'
const loggingCapturingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
console.log(`Capturing callback ${e.currentTarget.getAttribute('id')}`)
}
const handleGrandParentCaptureEvent = loggingCapturingCurrentTargetId
const handleParentCaptureEvent = loggingCapturingCurrentTargetId
const handleTargetButtonCaptureEvent = loggingCapturingCurrentTargetId
const loggingBubblingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
console.log(`Bubbling callback ${e.currentTarget.getAttribute('id')}`)
}
const handleTargetButtonEvent = loggingBubblingCurrentTargetId
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId
export const Event = () => {
return (
<div
id='grandParendDiv'
onClickCapture={handleGrandParentCaptureEvent}
onClick={handleGrandParentEvent}
>
<div
id='parendDiv'
onClickCapture={handleParentCaptureEvent}
onClick={handleParentEvent}
>
<button
id='currentButton'
onClickCapture={handleTargetButtonCaptureEvent}
onClick={handleTargetButtonEvent}
>
Click here
</button>
</div>
</div>
)
}
次に、おそらくあまり普段は使わないですが今回は挙動を確かめる目的として、addEventListener
を使ってdocumentとそれぞれの要素にログを仕込んでいきます。
export const Event = () => {
useEffect(() => {
document.addEventListener('click',() => {
console.log('Capturing addEventListener document')
}, { capture: true })
document.getElementById('grandParendDiv')?.addEventListener('click', () => {
console.log('Capturing addEventListener grandParendDiv')
}, { capture: true })
document.getElementById('parendDiv')?.addEventListener('click', () => {
console.log('Capturing addEventListener parendDiv')
}, { capture: true })
document.getElementById('currentButton')?.addEventListener('click', () => {
console.log('Capturing addEventListener currentButton')
}, { capture: true })
document.getElementById('currentButton')?.addEventListener('click', () => {
console.log('Bubbling addEventListener currentButton')
})
document.getElementById('parendDiv')?.addEventListener('click', () => {
console.log('Bubbling addEventListener parendDiv')
})
document.getElementById('grandParendDiv')?.addEventListener('click', () => {
console.log('Bubbling addEventListener grandParendDiv')
})
document.addEventListener('click', () => {
console.log('Bubbling addEventListener document')
})
}, [])
return (
...
)
}
ボタンを押してみる
これで準備は完了です。
ボタンをクリックすると、以下のようなログが出力されました。
2つの図にあるように、まずキャプチャリングフェーズのイベントハンドラが順次呼ばれますが、onClickCapture
で設定されたイベントハンドラはReactルート要素にイベントリスナが設定されているので、addEventListener
で設定されたイベントハンドラよりも早いタイミングで呼ばれています。逆にバブリングフェーズでは各要素に直接設定されたイベントリスナが先に発火しています。
このように、同じ要素にイベントリスナを設定しようとしても、addEventListener
を使ってしまうと順番が割り込まれてしまいます。
注意したほうがよさそうなこと
このイベントの発火のタイミングを理解しておかないと、stopPropagation
をしたときに意図しない挙動につながる可能性があります。
試しにボタンのonClick
にstopPropagation
を仕込んでみます。
-const handleTargetButtonEvent = loggingBubblingCurrentTargetId
+const handleTargetButtonEvent = (e: MouseEvent<HTMLButtonElement>) => {
+ loggingBubblingCurrentTargetId(e)
+ e.stopPropagation()
+}
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId
バブリングフェーズだけピックアップすると、ボタンにstopPropagation
を仕込んでも、addEventListener
で直接divに仕込んだイベントハンドラが先に発火してしまいます。このように、addEventListener
でReactルート以下の要素にコールバックを設定しておくとイベントの伝播を止める前に呼ばれてしまうことがあります。
そのため、Reactルート要素以下にaddEventListener
を設定することは注意が必要で、必要がないときは使わないほうが良さそうです。
最終的なコード
import { MouseEvent, useEffect } from 'react'
const loggingCapturingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
console.log(`Capturing callback ${e.currentTarget.getAttribute('id')}`)
}
const loggingBubblingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
console.log(`Bubbling callback ${e.currentTarget.getAttribute('id')}`)
}
const handleGrandParentCaptureEvent = loggingCapturingCurrentTargetId
const handleParentCaptureEvent = loggingCapturingCurrentTargetId
const handleTargetButtonCaptureEvent = loggingCapturingCurrentTargetId
const handleTargetButtonEvent = loggingBubblingCurrentTargetId
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId
export const Event = () => {
useEffect(() => {
document.addEventListener('click',() => {
console.log('Capturing addEventListener document')
}, { capture: true })
document.getElementById('grandParendDiv')?.addEventListener('click', () => {
console.log('Capturing addEventListener grandParendDiv')
}, { capture: true })
document.getElementById('parendDiv')?.addEventListener('click', () => {
console.log('Capturing addEventListener parendDiv')
}, { capture: true })
document.getElementById('currentButton')?.addEventListener('click', () => {
console.log('Capturing addEventListener currentButton')
}, { capture: true })
document.getElementById('currentButton')?.addEventListener('click', () => {
console.log('Bubbling addEventListener currentButton')
})
document.getElementById('parendDiv')?.addEventListener('click', () => {
console.log('Bubbling addEventListener parendDiv')
})
document.getElementById('grandParendDiv')?.addEventListener('click', () => {
console.log('Bubbling addEventListener grandParendDiv')
})
document.addEventListener('click', () => {
console.log('Bubbling addEventListener document')
})
}, [])
return (
<div
id='grandParendDiv'
onClickCapture={handleGrandParentCaptureEvent}
onClick={handleGrandParentEvent}
>
<div
id='parendDiv'
onClickCapture={handleParentCaptureEvent}
onClick={handleParentEvent}
>
<button
id='currentButton'
onClickCapture={handleTargetButtonCaptureEvent}
onClick={handleTargetButtonEvent}
>
Click here
</button>
</div>
</div>
)
}