【React】コンポーネントの外がクリックされたら閉じるポップアップメニューを実装しよう

ポップアップのカラーピッカーを React で作っているときに、いくつかの問題に直面したのでまとめておこうと思います。
作ったものは下のGIFのようなカラーピッカーです。
<a href="https://gyazo.com/e8217096583230c2494ff2fd966cc2b0"><img src="https://i.gyazo.com/e8217096583230c2494ff2fd966cc2b0.gif" alt="Image from Gyazo" width="244"/></a>

この記事では、カラーピッカー機能を取り除いた、下のポップアップメニューの作り方を紹介します。
完成品の CodePen を先に置いておくので、コピペしたい人はこちらをお使いください。

<p class="codepen" data-height="265" data-theme-id="default" data-default-tab="js,result" data-user="G4RDS" data-slug-hash="LYEPzGb" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="React Popup Menu">
  <span>See the Pen <a href="https://codepen.io/G4RDS/pen/LYEPzGb">
  React Popup Menu</a> by ジフォ (<a href="https://codepen.io/G4RDS">@G4RDS</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# ポップアップメニューの機能
今回作るポップアップメニューには以下のような機能があります。

- ボタンがクリックされるまでポップアップメニューを表示しない。
- ポップアップメニュー範囲外をクリックすると、ポップアップメニューを閉じる。
- ポップアップメニューの内側をクリックされても、ポップアップメニューは閉じない。
- ポップアップメニューの内側に、ポップアップメニューを閉じるボタンを用意する。

# ポップアップメニューを作ろう
Functional Component で作っていきます。
Hooks を使うので、基礎を理解している必要があります。

## まずはベースを作ろう
メニューの表示を切り替えるボタンと、メニューを持つコンポーネントを作ります。

```react
// PopupMenu.js
import './PopupMenu.scss'

const PopupMenu = () => {
  return (
    <div className="popup-menu-container">
      <button>
        Toggle Menu
      </button>
      <div className="popoup-menu">
        <div>menu</div>
        <button>
          Close Menu
        </button>
      </div>
    </div> 
  )
}
```

適当なスタイルを当てておきます。
`.popup-menu` がレイアウトに影響しないよう、`position: absolute` を適用します。

```scss
// PopupMenu.scss
.popup-menu-container {
  position: relative;
}

.popup-menu {
  position: absolute;
  z-index: 2;
  
  width: 10rem;
  margin-top: 0.5rem;
  padding: 1rem;
  background: #fff;
  border-radius: 0.5rem;
}
```

## メニューの表示を切り替えよう
React Hook の `useState` を使って、メニューを表示したり非表示にしたりできるようにします。

```react
// PopupMenu.js
import React, { useState } from 'react'

const PopupMenu = () => {
  const [isShown, setIsShown] = useState(false)

  const handleToggleButtonClick = () => {
    setIsShown(true)
  }
  
  const handleCloseButtonClick = () => {
    setIsShown(false)
  }
  
  return (
    <div className="popup-menu-container">
      <button onClick={handleToggleButtonClick}>
        Toggle Menu
      </button>
      <div className={`popup-menu ${isShown ? 'shown' : ''}`}>
        <div>menu</div>
        <button onClick={handleCloseButtonClick}>
          Close Menu
        </button>
      </div>
    </div> 
  )
}
```

```scss
// PopupMenu.scss
.popup-menu {
  transform: scale(0);
  transform-origin: top left;

  transition: transform 0.2s;
  
  &.shown {
    transform: scale(1);
  }
}
```

## ポップアップメニュー範囲外がクリックされたら閉じるようにしよう
click イベントは通常、クリックされた場所に一番近い要素から遠い要素へ順番にイベントリスナーを実行していきます。
つまり、HTML DOM の一番外側である `body` に設定されたイベントリスナーは、そこに伝播するまでの要素に設定されたイベントリスナーが伝播を止めない限り、どこがクリックされても必ず実行されます。

### とりあえず、どこをクリックしても閉じるようにしよう
この機能を実装するには、`document.body` にポップアップメニューを閉じるためのイベントリスナーを設定すれば良さそうです。

```react
const handleToggleButtonClick = () => {
  setIsShown(true)

  document.body.addEventListener('click', e => {
    setIsShown(false)
    document.body.removeEventListener('click', documentClickHandler)
  })
}
```

しかし、このコードには問題があります。

React DOM の onClick などのイベントリスナーは、HTML DOM のイベントリスナーよりも後で呼ばれることが確定しているため、トグルボタンをクリックした時に `isShown` が `document.body` に設定したイベントリスナーによって `false` になった後、トグルボタンのイベントリスナーが `true` にしてしまうのです。
また、`isShown` を `true` にした後、`document.body` にイベントリスナーを再設定してしまいます。

これを解決するには、範囲外イベントリスナーを React DOM イベントリスナーよりも後に実行される場所に設定する必要があります。
それは、`document` です。
React DOM は `document` にクリックイベントリスナーを設定することで、どの React DOM 要素がクリックされたのかを判断しています。
同じ要素に複数のリスナーが設定されている場合、先に設定されたリスナーから順番に実行されます。
つまり、React DOM と同じ `document` にイベントリスナーを設定することで、React DOM のイベントリスナーが処理された後に実行されるようにできるのです。

```react
document.addEventListener('click', e => {
  setIsShown(false)
  document.removeEventListener('click', documentClickHandler)
})
```

### ポップアップメニューの範囲がクリックされたのなら閉じないようにしよう
現状だとメニューを利用することができないので、ポップアップメニューの範囲内かどうかを判定し、範囲内なら閉じる処理を行わないようにしましょう。
これには、二つの実装方法が考えられます。

- ポップアップメニュー `.popup-menu` に、これ以上イベントが伝播しないようにするイベントリスナーを設定する。
- `document` に設定したイベントリスナーで、クリック対象の要素がポップアップメニュー要素もしくはその子要素であるかどうかを判定する。

簡単なのは前者ですが、React DOM のイベントリスナーで `e.stopPropagation()` を実行しても、HTML DOM のイベント伝播は止まらないため、機能しません。
面倒ですが、後者の方法で実装します。

#### ポップアップメニュー要素にアクセスできるようにする
まず、ポップアップメニュー要素を取得するため、`useRef` フックを使います。

```react
const popupRef = useRef()

// ...

<div
  className={`popup-menu ${isShown ? 'shown' : ''}`}
  ref={popupRef}
>
  ...
</div>
```

これで、`popupRef.current` でポップアップメニュー要素にアクセスできるようになりました。

#### クリックされた要素がポップアップメニュー要素以下であるかを判定する
`popupRef.current` で取得できる `Element` のスーパークラスである `Node` には、引数に指定したノードがこのノードの子孫ノード（自分自身を含む）であるかどうかを判定する [Node.contains](https://developer.mozilla.org/ja/docs/Web/API/Node/contains) メソッドがあります。
これを使うことで、クリックされた要素がポップアップメニュー要素以下であるかどうかを判定できます。

```react
const documentClickHandler = e => {
  if (popupRef.current.contains(e.target)) return

  setIsShown(false)
  document.body.removeEventListener('click', documentClickHandler)
}
```

## ポップアップメニュー内に閉じるボタンを用意しよう
一番初めに紹介したカラーピッカーでは、カラーが選択されたらポップアップメニューを閉じるようにしています。
この機能を実装してみましょう。

閉じるときに、`document` に設定したイベントリスナーを削除するのを忘れないようにしましょう！

```react
const documentClickHandler = e => {
  if (popupRef.current.contains(e.target)) return

  setIsShown(false)
  removeDocumentClickHandler()
}

const removeDocumentClickHandler = () => {    
  document.removeEventListener('click', documentClickHandler)
}

const handleCloseButtonClick = () => {
  setIsShown(false)
  removeDocumentClickHandler()
}
```

しかし、このコードは正しく動作しません。
`removeEventListner` は第二引数が同じ参照を指すイベントリスナーを除去するという働きをする関数ですが、`documentClickHandler` がReact がレンダリングするたびに新しく生成されてしまうため、イベントリスナーを除去できずに残り続けてしまいます。
これを解決するために、useRef を使った `documentClickHandler` インスタンス変数を用意し、`useEffect` で最初のレンダリング後に関数をセットするようにします。

```react
const PopupMenu = () => {

  // ...

  const documentClickHandler = useRef()

  useEffect(() => {
    documentClickHandler.current = e => {
      // ...
    }
  }, [])

  const removeDocumentClickHandler = () => {
    document.removeEventListener('click', documentClickHandler.current)
  }

  const handleToggleButtonClick = () => {
    // ...
    document.addEventListener('click', documentClickHandler.current)
  }
```

正しく動作するようになりましたね！

# 🎉 完成！ お疲れ様でした
React DOM は HTML DOM の要素にセットしているイベントリスナーはダミーで、実際は `document` にセットしたイベントリスナーが処理していたとは、まったく気づきませんでした。

今回はメニューの内部も含めて一つのコンポーネントにしましたが、実際に運用する React プロジェクトでは機能だけを提供するコンポーネントを用意して、再利用できるようにすると良いのかなと思います。

最後に宣伝をさせてください！

今年の春ごろに、毎日やりたいことを習慣づけるアプリ「コツコツ忍者」を作りました！
毎日開発する！とか、毎週土日はランニングする！とかの目標を作り、記録することでモチベーションを保つアプリです！
Nuxt.js で作ったもので、[何を使っているのかなどを書いた記事](https://qiita.com/G4RDSjp/items/12a48f8c3e146e3545b2)もありますので、ぜひそちらもご覧ください。

また、英語リスニング学習アプリを Flutter で開発中です。
ある程度までできたら、事前登録サイトを用意しようと思っていますので、[Twitter](https://twitter.com/G4RDS)をフォローしていただけると幸いです！

最後までご覧いただきありがとうございました！

# 参考文献
- **DOMイベントのキャプチャ/バブリングを整理する 〜 JSおくのほそ道 #017**  
  https://qiita.com/hosomichi/items/49500fea5fdf43f59c58
- **Event propagation with React bubbling out of order**  
  https://stackoverflow.com/questions/45212840/event-propagation-with-react-bubbling-out-of-order