Reactでコンポーネントの外側がクリックされた際にドロップダウンやモーダルを閉じる等の動作をする

ToC

導入

こういうやつを関数コンポーネント+フックで書きます。

f:id:seiyab:20200926225940g:plain

Material-UI等にも存在しますが、それだけのために依存を追加したくない場合や勉強のために実装しました。 少し検索した感じだと他の日本語記事はクラスコンポーネントのものだったり、フックを使っていても汎用コンポーネントではなかったり副作用の管理の仕方が異なっていたので記事にすることにしました。

実装

onClickに関数を渡すとコンポーネント外をクリックした時に実行してくれるコンポーネントを実装しました。

import { useRef, useEffect } from 'react';

type Props = {
  onClick: (event: MouseEvent) => void;
}

const OutsideClickListener: React.FC<Props> = ({ children, onClick }) => {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  useEffect(
    () => {
      const listener = (event: MouseEvent) => {
        if (wrapperRef.current === null) return;
        if (event.composedPath().includes(wrapperRef.current)) return;
        onClick(event);
      } 
      document.addEventListener('click', listener);
      return () => document.removeEventListener('click', listener);
    },
    [onClick],
  );

  return (
    <div ref={wrapperRef}>
      {children}
    </div>
  );
};

export default OutsideClickListener;

使い方の例

type Props = {
  content: JSX.Element;
};

const Dropdown: React.FC<Props> = ({ content, children }) => {
  const [isOpened, setIsOpened] = useState(false);
  const toggle = useCallback(
    () => setIsOpened((prev) => !prev),
    [setIsOpened],
  );
  const close = useCallback(
    () => setIsOpened(false),
    [setIsOpened],
  );
  return (<div className={style.dropdown}>
    <OutsideClickListener onClick={close}>
      <button
        className={style.button}
        onClick={toggle}
        type="button"
      >
        {children}
      </button>
      {isOpened && <div className={style.content}>
        {content}
      </div>}
    </OutsideClickListener>
  </div>)
};

export default Dropdown;

解説

コンポーネント外クリックの取得

コンポーネントの外側のイベントを取得したいので、document.addEventListenerを使い全体に対するイベントリスナーを使用します。 クリックされた箇所がOutsideClickListener内の要素でない場合にonClickを実行したいので、document.addEventListenerに渡す関数は次のように書いています。

const listener = (event: MouseEvent) => {
  if (wrapperRef.current === null) return;
  if (event.composedPath().includes(wrapperRef.current)) return;
  onClick(event);
} 

wrapperRefOutsideClickListener自身であるdivに対するrefです。

最初のif文は、wrapperRefの初期値がnullになってしまうのでrefがセットされる前は何もしないようにするearly returnです。

2つ目のif文は、クリックされた要素がwrapperRef内の要素の場合は何もしないようにするearly returnです。 event.composedPath()はイベントに対するリスナーを実行する要素の配列を取得しています。 OutsideClickListener内の要素の場合はバブリングによりOutsideClickListenerに到達するので、includes(wrapperRef.current)trueになります。 この条件文は下記のように実装してもいいかもしれません。

if (event.target instanceof Node && wrapperRef.current.contains(event.target)) return;

リスナーの登録・解除

上記のイベントリスナーはコンポーネントのマウント時に登録し、アンマウント時に解除したいです。 このようにライフサイクルに応じて副作用を起こすにはuseEffectを使用します。

useEffect(
  () => {
    const listener = (event: MouseEvent) => {
      if (wrapperRef.current === null) return;
      if (event.composedPath().includes(wrapperRef.current)) return;
      onClick(event);
    } 
    document.addEventListener('click', listener);
    return () => document.removeEventListener('click', listener);
  },
  [onClick],
);

副作用としてdocument.addEventListener('click', listener)を実行します。 また、アンマウント時や再登録時のクリーンアップ関数として戻り値に() => document.removeEventListener('click', listener) を設定しています。

参考・関連