React17のevent delegationの破壊的変更を理解する

11 min read読了の目安(約10700字

React17が出てからしばらく経ちましたが、React17の破壊的変更で既存コードが動かないということがあり、調査と修正を行いました。
そこで調査の過程で得られたことを、自分自身の理解の整理も兼ねてまとめておきます。

本記事ではReact17の破壊的変更のうち、event delegationにおけるイベントの委譲先の変更について取り上げます。
この変更については公式ブログでの説明がとても分かりやすかったですが、実際にどんなユースケースで問題になるのかという点を詳しく解説できたらと思います。

event delegation(イベントの委譲)とは?

いきなり聞き慣れない言葉なので、まずはevent delegationとは何かという部分から確認していきましょう。
通常Reactでイベントハンドラを登録する場合、以下のようにインラインで記述します。

<button id="myButton" onClick={handleClick}>ボタン</button>

このコードは実際のDOMでは以下とほぼ同等です。

document.getElementById('myButton').addEventListenrer('click', handleClick)

しかし、実際Reactは裏側でこのような処理を行っているわけではありません。
ReactはDOMノードにイベントハンドラを直接追加することはせず、イベントタイプごとにハンドラを1つだけdocumentオブジェクトに付与します。
この挙動は実際にブラウザで確認することができるので試してみましょう。

先ほど例で挙げたような、ボタンにイベントハンドラを設定したReactコンポーネントをブラウザの検証ツールで見てみました。
するとdocumentオブジェクトに対してclickイベントのハンドラが1つだけ登録されていることが分かります。
逆にbuttonからonClickイベントを削除すればdocumentからも消えます。
React16系のイベントデリゲーション
documentのclickに対してイベントハンドラが設定されている

このように、ほぼすべてのイベントハンドラを内部的にdocumentオブジェクトに対して設定するというのがReact16までのevent delegationという仕組みです。

React17でevent delegationはどう変わったのか?

では、React17ではevent delegationの挙動がどのように変わったのでしょうか?
一言でいうと、イベントハンドラの設定先がdocumentからReactアプリがマウントされるルート要素に変わりました。
つまり、React17では裏側で、document.addEventListener() ではなく rootNode.addEventListener() という呼び出しを行うようになります。
この変更に関しては公式ブログの画像が非常にわかりやすいので引用します。

イベントの委譲のイメージ図
公式ブログから引用

この変更も同じくブラウザの検証ツールで確認することができます。
reactを17系にして、先ほどと同じボタンのコンポーネントをブラウザで確認してみましょう。
するとdocumentオブジェクトではなく、div#appに対してイベントハンドラが設定されていることが分かりますね。
React17系のイベントデリゲーション
div#appのclickに対してイベントハンドラが設定されている

ここまでのポイントは以下の2点です。

  • React16まではdocumentに対してイベントが委譲される。
  • React17からはrootNodeにイベントが委譲される。

それでは、この2点を踏まえてどんなケースでこの変更が問題になるのかを実際に見ていきます。

問題となるケース

さて、ここまではevent delegationの概要とReact17でどんな変更があったかを見てきました。
ここからはこの破壊的変更がどんなケースで問題になったのかを、実例を交えて紹介していきます。

ポップアップメニューを考える

event delegationの破壊的変更がどんな影響を与えるのかを知るために、以下のような仕様をもつPopupMenuコンポーネントを考えます。

  • openボタンをクリックすると、メニューが開く
  • 開いている状態でメニューの範囲外(openボタン含む)をクリックすると、メニューが閉じる
  • メニュー内のcloseボタンをクリックすると、メニューが閉じる

実際の動きと一緒に確認してみてください。非常によくあるUIですね。

いきなりですが、このPopupMenuコンポーネントをReact16で動作するように実装したコードの全体像を載せておきます。

PopupMenu.tsx
import React, { useCallback, useRef, useState } from 'react';
import './PopupMenu.scss'

type Props = {}

export const PopupMenu: React.VFC<Props> = (props) => {
  const [isOpen, setIsOpen] = useState<boolean>(false)
  const popupRef = useRef<HTMLDivElement | null>(null);

  const outsideClickHandler = useCallback((e: MouseEvent) => {
    if (popupRef.current?.contains(e.target as Node)) return
    setIsOpen(false)
    removeOutsideClickHandler()
  }, [popupRef])

  const openButtonClickHandler = useCallback(() => {
    setIsOpen(true)
    addOutsideClickHandler()
  }, [])

  const closeButtonClickHandler = useCallback(() => {
    setIsOpen(false)
    removeOutsideClickHandler()
  }, [])

  const addOutsideClickHandler = useCallback(() => {
    document.addEventListener('click', outsideClickHandler)
  }, [])

  const removeOutsideClickHandler = useCallback(() => {
    document.removeEventListener('click', outsideClickHandler)
  }, [])

  return (
    <>
      <button onClick={openButtonClickHandler}>open</button>
      <div className='popup' data-popup-active={isOpen} ref={popupRef}>
        <button onClick={closeButtonClickHandler}>close</button>
      </div>
    </>
  )
};

長いですが、少しずつ分割して確認していきましょう。

まずuseStateでメニューの表示・非表示の状態を管理しています。
本記事ではスタイルは省略しますが、表示・非表示のスタイルを当てるため、data-popup-activeというカスタムdata属性を付与しています。

PopupMenu.tsx
const [isOpen, setIsOpen] = useState<boolean>(false)
...
return (
  <>
    <button onClick={openButtonClickHandler}>open</button>
    <div className='popup' data-popup-active={isOpen} ref={popupRef}>
      <button onClick={closeButtonClickHandler}>close</button>
    </div>
  </>
)

次にoutsideClickHandlerという関数を見てみましょう。

ここではuseRefを使って開閉されるメニューのDOMへの参照を保存しています。
popupRef.currentで取得できるElementのスーパークラスであるNodeには Node.contains()というメソッドがあります。
これは引数に指定したノードがこのノードの子孫ノード(自分自身を含む)であるかどうかを判定する関数なので、これを利用して範囲外クリックかどうかを判定しています。

つまり、メニュー範囲外がクリックされたらメニューを閉じて自分自身をdocumentのイベントリスナから削除するという処理を行っています。

PopupMenu.tsx
...
const popupRef = useRef<HTMLDivElement | null>(null);
const outsideClickHandler = useCallback((e: MouseEvent) => {
  if (popupRef.current?.contains(e.target as Node)) return
  setIsOpen(false)
  removeOutsideClickHandler()
}, [popupRef])
...

最後に残りの部分です。

  • openボタンがクリックされたらメニューを表示して、documentのclickイベントに対してoutsideClickHandlerを追加。
  • 逆にcloseボタンがクリックされたらメニューを非表示にして、documentのclickイベントからoutsideClickHandlerを削除。
    という一連の処理を行うための関数です。
PopupMenu.tsx
...
const openButtonClickHandler = useCallback(() => {
  setIsOpen(true)
  addOutsideClickHandler()
}, [])

const closeButtonClickHandler = useCallback(() => {
  setIsOpen(false)
  removeOutsideClickHandler()
}, [])

const addOutsideClickHandler = useCallback(() => {
  document.addEventListener('click', outsideClickHandler)
}, [])

const removeOutsideClickHandler = useCallback(() => {
  document.removeEventListener('click', outsideClickHandler)
}, [])
...

前述したとおり、このコードはReact16では問題なく動作します。

しかし、ここではなぜ「documentに対してoutsideClickHandlerを追加している」のでしょうか?
単に範囲外クリックを検知するためのイベントリスナを登録するだけなら、document.bodyなどの要素でも問題ないように思えます。

その答えは、React16までのevent delegationではdocumentに対してイベントが委譲されるという点と関係しています。

より詳しく知るために、React16でdocumentに対してoutsideClickHandlerを追加した場合とdocument.bodyに対してoutsideClickHandlerを追加した場合を比較してみます。

document.bodyに範囲外クリックのイベントリスナを設定した場合の処理の流れ(悪い例)

openボタンでメニュー表示、closeボタンまたは範囲外クリックでメニューを閉じるとこまではうまく動いています。
ただ、メニューが開いている状態でopenボタンをクリックしてもメニューが閉じてくれません。

何が起きているのかをまとめてみました。
是非、何度も読みながら試してみてください。

  1. 最初にopenボタンを押す
  2. openボタンからdocumentに委譲されたイベントハンドラによって、メニューを表示しdocument.bodyoutsideClickHandlerを追加
  3. 再度openボタンを押すとdocument > document.bodyの階層関係であるので、バブリングによってdocument.bodyの処理→documentの処理の順番で発火
  4. document.bodyのイベントによってメニューを閉じて、document.bodyからoutsideClickHandlerを削除
  5. openボタンからdocumentに委譲されたイベントによってメニューを表示し、document.bodyoutsideClickHandlerを追加

結果としてメニューが開いた状態で、openボタンを押しても閉じれないということになります。
対してdocumentに対してoutsideClickHandlerを設定した場合を見てみます。

outsideClickHandlere.stopPropagation()を加えることでも3のバブリングを防げるのでうまく動きますが、今回はevent delegationの挙動を理解する目的のため、documentに設定する方法で対処していきます。

documentに範囲外クリックのイベントリスナを設定した場合の処理の流れ(良い例)

こちらは最初に掲示したとおり、documentに対してoutsideClickHandlerを追加した場合のコードです。
これは期待通りに動いてくれていますね。

こちらも何が起きているのかをまとめてみました。
何度も読みながら試してみると理解が深まるかもしれません。

  1. 最初にopenボタンを押す
  2. openボタンからdocumentに委譲されたイベントハンドラによって、メニューを表示しdocumentoutsideClickHandlerを追加
  3. 再度openボタンを押すとどちらもdocumentに対するイベントハンドラが設定されているので、追加された順番で発火
    (openボタンからdocumentに委譲された処理→documentにあとから追加したoutsideClickHandlerの処理の順番で発火)
  4. openボタンからdocumentに委譲されたイベントハンドラによって、メニューを表示しdocumentoutsideClickHandlerを追加
  5. documentにあとから追加したoutsideClickHandlerの処理によって、メニューを閉じてdocumentからoutsideClickHandlerを削除

このようにevent delegationによって委譲される先と同じ対象にあとからイベントリスナを追加してあげることで、ReactDOMの処理→あとから追加した処理という順番で処理してくれるようになるので期待通りに動作するというわけですね。

React17で動作確認してみる

ここまで確認してきた以下のコードはそのままに、react, react-domのバージョンを17系にあげてみます。

PopupMenuのコード(再掲)
PopupMenu.tsx
import React, { useCallback, useRef, useState } from 'react';
import './PopupMenu.scss'

type Props = {}

export const PopupMenu: React.VFC<Props> = (props) => {
  const [isOpen, setIsOpen] = useState<boolean>(false)
  const popupRef = useRef<HTMLDivElement | null>(null);

  const outsideClickHandler = useCallback((e: MouseEvent) => {
    if (popupRef.current?.contains(e.target as Node)) return
    setIsOpen(false)
    removeOutsideClickHandler()
  }, [popupRef])

  const openButtonClickHandler = useCallback(() => {
    setIsOpen(true)
    addOutsideClickHandler()
  }, [])

  const closeButtonClickHandler = useCallback(() => {
    setIsOpen(false)
    removeOutsideClickHandler()
  }, [])

  const addOutsideClickHandler = useCallback(() => {
    document.addEventListener('click', outsideClickHandler)
  }, [])

  const removeOutsideClickHandler = useCallback(() => {
    document.removeEventListener('click', outsideClickHandler)
  }, [])

  return (
    <>
      <button onClick={openButtonClickHandler}>open</button>
      <div className='popup' data-popup-active={isOpen} ref={popupRef}>
        <button onClick={closeButtonClickHandler}>close</button>
      </div>
    </>
  )
};

これを動作確認すると、そもそもopenボタンを押しても開くことができません。

React17ではイベントの委譲先がrootNodeになったということを思い出して、どんな処理が起きているのかを考えてみましょう。

  1. openボタンを押す
  2. openボタンからdiv#appに委譲されたイベントハンドラによって、メニューを表示しdocumentoutsideClickHandlerを追加
  3. 2の終了時でdocument > div#appの階層関係なのでバブリングによってdocumentの処理も即座に発火する
  4. documentの処理でメニューを消して、documentからoutsideClickHandlerを削除

つまり問題は1回目にopenボタンを押した時、バブリングによってdocumentに設定した処理も発火している点です。
そのせいでメニューが表示になった瞬間にメニューが非表示にされてしまうということです。

これを動くように修正していきます。

コードを修正する

それではReact17でも期待通りに動くように修正していきます。
修正といっても、outsideClickHandlerの登録先をReact17のイベントの委譲先と同じ,div#appにしてあげるだけです。

PopupMenu.tsx
...
  const addOutsideClickHandler = useCallback(() => {
-   document.addEventListener('click', outsideClickHandler)
+   document.querySelector('#app').addEventListener('click', outsideClickHandler)
  }, [])

  const removeOutsideClickHandler = useCallback(() => {
-   document.removeEventListener('click', outsideClickHandler)
+   document.querySelector('#app').removeEventListener('click', outsideClickHandler)
  }, [])
...

この修正によって処理は以下のように変わります。

  1. openボタンを押す
  2. openボタンからdiv#appに委譲されたイベントハンドラによって、メニューを表示しdiv#appoutsideClickHandlerを追加
  3. 2の終了時でoutsideClickHandlerdiv#appというReactのイベント委譲先と同じ階層に設定されただけなので、バブリングによってoutsideClickHandlerの処理が即座に発火することはない

これで期待通りに動作するようになりました🎉
お疲れさまでした!

補足

今回はevent delegationの挙動を理解する目的のため、イベントの委譲先と同じオブジェクトに自分で追加したいイベントハンドラを追加するという方針で修正しましたが、他にも色々やり方があると思います。
例えば、

  • メニューが開いている時に、画面いっぱいの背景要素を表示して、それに対してクリックイベントを追加する
  • useEffectを使ってイベントリスナの追加・削除を管理する

などです。
むしろこっちのほうが簡単かもしれませんが、今回の趣旨と外れるので割愛します。

まとめ

本記事ではReact17におけるevent delegationの破壊的変更をまとめました。
今回紹介したPopupMenuのようにdocumentに対してイベントリスナを自分で設定するようなケースは少ないかもしれませんが、少しでも理解の助けとなれば幸いです。

参考記事

https://ja.reactjs.org/blog/2020/08/10/react-v17-rc.html
https://qiita.com/G4RDSjp/items/58364a6655d4968a90d9