Reactにおけるイベント伝播・イベントデリゲーションの挙動を整理した

## はじめに
Reactでのイベントの特徴としてイベントデリゲーションを使っている点がありますが、この理解をするために手を動かしながら整理しました。

### イベントデリゲーションについて

Reactでは`onClick`などで各要素にイベントリスナを設定することができますが、実際はその対象のDOMノードにイベントハンドラを設定していません。ReactはそれをReactルート要素にイベントデリゲーションして内部的にイベントの処理をしているようです。

この処理はReact 17で一部変更があった部分で、React 17のリリースブログに画像つきで解説されています。([イベントデリゲーションに関する変更](https://ja.reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation))

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143317/16c14810-65f8-657b-d904-891716dc9408.png" width="600px">

[ブログで紹介されているこちらの記事](https://davidwalsh.name/event-delegate)によると、要素が追加・削除されるごとに各要素のイベントリスナを設定し直す必要がなくなるというメリットがあるようです。

### DOMイベントフローについて
もう一つイベントの処理で知っておく必要があるのは、DOMイベントフローについてです。

クリックなどのイベントが発火すると、キャプチャリング、バブリングフェーズというフェーズに分かれてイベントが伝播します。

https://developer.mozilla.org/ja/docs/Learn/JavaScript/Building_blocks/Events#event_bubbling_and_capture

W3Cの[3.1. Event dispatch and DOM event flow](https://www.w3.org/TR/DOM-Level-3-Events/)にわかりやすい画像があるので、そちらを引用します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143317/754a29db-9b38-13ee-2f43-451174039ee0.png" width="400px">

Reactで`onClick`などでイベントリスナを設定するのはバブリングフェーズで、キャプチャリングフェーズに設定する場合は`onClickCapture`などを使用します。
`addEventListener`でキャプチャリングフェーズにイベントリスナを設定するには、第3引数に`{ capture: true }`を設定します。

これ以降はこの2つの図をイメージしながら読んでもらえたらと思います。

#### 2つの図

 | [3.1. Event dispatch and DOM event flow](https://www.w3.org/TR/DOM-Level-3-Events/) | [イベントデリゲーションに関する変更](https://ja.reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation)|
|:-:|:-:|
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143317/754a29db-9b38-13ee-2f43-451174039ee0.png" width="400px">  |  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143317/16c14810-65f8-657b-d904-891716dc9408.png" width="600px"> | |

## 実装

### 準備

今回は以下のようなdiv2つに囲まれたbuttonをクリックするときのイベントの挙動を見てみます。

```jsx
export const Event = () => {
  return (
    <div id='grandParendDiv'>
      <div id='parendDiv'>
        <button id='currentButton'>
          Click here
        </button>
      </div>
    </div>
  )
}
```

各要素のキャプチャリングフェーズ、バブリングフェーズのイベントリスナにログを出力するコールバックをイベントハンドラとして設定していきます。

```jsx
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とそれぞれの要素にログを仕込んでいきます。

```jsx
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 (
...
  )
}
```

### ボタンを押してみる

これで準備は完了です。

ボタンをクリックすると、以下のようなログが出力されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143317/8b4d59c0-2315-99b2-8ac8-577d110a8423.png)

<a href="#2つの図">2つの図</a>にあるように、まずキャプチャリングフェーズのイベントハンドラが順次呼ばれますが、`onClickCapture`で設定されたイベントハンドラはReactルート要素にイベントリスナが設定されているので、`addEventListener`で設定されたイベントハンドラよりも早いタイミングで呼ばれています。逆にバブリングフェーズでは各要素に直接設定されたイベントリスナが先に発火しています。

このように、同じ要素にイベントリスナを設定しようとしても、`addEventListener`を使ってしまうと順番が割り込まれてしまいます。

## 注意したほうがよさそうなこと

このイベントの発火のタイミングを理解しておかないと、`stopPropagation`をしたときに意図しない挙動につながる可能性があります。

試しにボタンの`onClick`に`stopPropagation`を仕込んでみます。

```diff_typescript
-const handleTargetButtonEvent = loggingBubblingCurrentTargetId
+const handleTargetButtonEvent = (e: MouseEvent<HTMLButtonElement>) => {
+  loggingBubblingCurrentTargetId(e)
+  e.stopPropagation()
+}
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId
```

バブリングフェーズだけピックアップすると、ボタンに`stopPropagation`を仕込んでも、`addEventListener`で直接divに仕込んだイベントハンドラが先に発火してしまいます。このように、`addEventListener`でReactルート以下の要素にコールバックを設定しておくとイベントの伝播を止める前に呼ばれてしまうことがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/143317/2dc2229a-6e30-e5bc-2933-345ff67b050c.png)

そのため、Reactルート要素以下に`addEventListener`を設定することは注意が必要で、必要がないときは使わないほうが良さそうです。

<details><summary>最終的なコード</summary>

```jsx
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>
  )
}
```

</details>

## 参考

- https://ja.reactjs.org/blog/2020/08/10/react-v17-rc.html#changes-to-event-delegation
- https://davidwalsh.name/event-delegate
- https://developer.mozilla.org/ja/docs/Learn/JavaScript/Building_blocks/Events#event_bubbling_and_capture
- https://www.w3.org/TR/DOM-Level-3-Events/