Reactでコンポーネントの外側がクリックされた際にドロップダウンやモーダルを閉じる等の動作をする
ToC
導入
こういうやつを関数コンポーネント+フックで書きます。
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); }
wrapperRef
はOutsideClickListener
自身である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)
を設定しています。
参考・関連
- MDN
- React
- Matrial-UI
- Stack Overflow