no7.space
🔎

要素の外側のクリックを検知する

メニューだったり、モーダルのような UI には、なかには要素の外側をクリックすると表示を閉じる仕組みのものも多くあります。
このサイトもメニューが1つ増えたことで SP 表示のときはハンバーガーメニューにすることにしたのですが、それに合わせて、メニューの外を押したら閉じられるようにしてみます。

カスタムフックをつくる

参考サイト(最後にリンクします)のコードほぼそのままなのですが、こういう感じのフックを作ります。

import type React from 'react'
import { useEffect } from 'react'

export function useCheckOutsideClick(
  ref: React.RefObject<HTMLElement | null>,
  handler: (event: MouseEvent | TouchEvent) => void
) {
  useEffect(() => {
    function handleClickOutside(event: MouseEvent | TouchEvent) {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        handler(event)
      }
    }

    document.addEventListener('mousedown', handleClickOutside)
    document.addEventListener('touchstart', handleClickOutside)
    return () => {
      document.removeEventListener('mousedown', handleClickOutside)
      document.removeEventListener('touchstart', handleClickOutside)
    }
  }, [ref, handler])
}

呼び出し方

export default function SiteHeader() {
  const [isMenuOpen, setIsMenuOpen] = useState(false)
  const navElementRef = useRef(null)
  useCheckOutsideClick(navElementRef, () => setIsMenuOpen(false))

  const navClassNames = [
    styles['site-header-nav'],
    isMenuOpen ? styles['site-header-nav--isopen'] : '',
  ]

  return (
    <nav className={navClassNames.join(' ')} ref={navElementRef}>
      // 略
    </nav>
  )
}

useCheckOutsideClick に監視したい要素の ref を渡すと、useEffect でイベントリスナーを登録して、
クリックイベントが発生した時に ref.current の外側がクリックされたかをチェックして、条件を満たしたときだけ handler を呼び出す。
最後にアンマウント時にイベントリスナーをクリーンアップするように設定しておく、みたいな流れです。

なんでこれで外側をクリックしたかがわかるのか

ref として監視対象の要素が送られてくるので、ref.current.contains(event.target as Node) で現在クリックした要素が監視対象の要素を内包してるかどうかを確認し、内包しているなら、対象の要素の内側をクリックしている、そうでなければ外側をクリックしているというふうに判断できるということみたいです。

外側クリックした時に第2引数の処理が実行される仕組み

useCheckOutsideClick は2つの引数を受けています。1つ目は上にも書いた ref で、2つめは handler となってます。
このhandler は、所謂コールバック関数とよばれるもので、useCheckOutsideClick の外で定義した関数を持ち込んで内部で handler() という名前で呼び出せるようにしているものです。

if (ref.current && !ref.current.contains(event.target as Node)) {
  handler(event)
}

というふうに条件分岐を行っていて、条件を満たした時に handler(event) が呼び出されるのですが、その実態は useCheckOutsideClick の第2引数に書いた関数だった、という感じです。

handleClickOutside の処理について

クリックイベントをリッスンする部分ですが、

 document.addEventListener('click', handleClickOutside)

みたいな感じで、クリックイベントを拾う方法もあるようです。
ただ、厳密にはタイミングが若干違うみたいで、mousedowntouchstart それぞれをリッスンするほうがより素早く確実な動作が見込める
……と clpilot が教えてくれたので今回はこっちでやってみました。

感想

カスタムフックをつくるってことで身構えてしまってましたが、実装をよく呼んでみたら特別なことはなにもしてなくて、基本的な構造は至ってシンプルな感じでした。
一度覚えてしまえば簡単にいろいろつくれそうで良いですね。

内部の判定処理もシンプルなので、1箇所だけしか使わないのなら、分割しないでも十分書ききれるボリュームかなとも思います。
が、結構汎用的に使えそうなので、フック化してどっかに切り出しておくとあとでラクできるのかなーとも思いました。

参考