Reactで、外側をクリックしたら閉じるカスタムフックの作り方

​​React​createPortal,Playwright,TypeScript,useEffectEvent,カスタムフック

メニューの外側をクリックしたら閉じる処理は、document で pointerdown を待ち、押された場所がメニューの要素の中かどうかを調べるカスタムフックにまとめられます。使う側のコンポーネントでは、内側とみなす要素の ref と閉じる関数を渡す1行で済みます。

このフックを書くときに気をつける点は3つあります。開くボタンも内側に入れること、click ではなく pointerdown を待つこと、ポータルで別の場所に描画した部分を内側として扱うことです。どれも、そうしなかった版と動きを比べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chrome for Testing 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.56.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作したブラウザで動かしました。StrictMode の確認だけは開発用のビルドで、それ以外は本番用のビルドで動かしています。タッチ操作は、Playwright のタッチ対応の画面で tap を使って再現したもので、実機では試していません。Firefox と Safari、iframe の中のクリックも試していません。

フックの全体

内側とみなす要素の ref の配列、外側が押されたときに呼ぶ関数、フックを働かせるかどうかの3つを受け取ります。

ts
import { useEffect, useEffectEvent, type RefObject } from 'react';

// targets のどの要素の外側が押されても onOutside を呼ぶ。enabled が false のあいだは何もしない
export function useClickOutside(
  targets: RefObject<HTMLElement | null>[],
  onOutside: (event: PointerEvent) => void,
  enabled = true,
) {
  // 使う側が描画のたびに新しい関数や配列を渡しても、登録し直さない
  const onPointerDown = useEffectEvent((event: PointerEvent) => {
    const node = event.target as Node;
    if (targets.some((target) => target.current?.contains(node))) return;
    onOutside(event);
  });

  useEffect(() => {
    if (!enabled) return;
    document.addEventListener('pointerdown', onPointerDown);
    return () => document.removeEventListener('pointerdown', onPointerDown);
  }, [enabled]);
}

内側かどうかは、MDN の Node.contains() のページにあるとおり、押された要素が ref の要素そのものか、その子孫かで決まります。

リスナーの中身は useEffectEvent で包みました。useEffectEvent のリファレンスにある、イベントリスナーで最新の値を読む例と同じ形です。使う側は [menuRef, buttonRef] のような配列や () => setOpen(false) のような関数を描画のたびに新しく作って渡しますが、依存配列に入れずに済むので、そのたびにリスナーを外して付け直すことはありません。

メニューで使う

メニューを開いているあいだだけフックを働かせ、外側が押されたら閉じます。

tsx
function Menu() {
  const [open, setOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  useClickOutside([menuRef, buttonRef], () => setOpen(false), open);

  return (
    <>
      <button ref={buttonRef} onClick={() => setOpen((o) => !o)}>メニュー</button>
      {open && (
        <div ref={menuRef} className="menu">
          <p>並べ替え</p>
          <button>名前順</button>
        </div>
      )}
    </>
  );
}

この Menu をブラウザに表示し、開く、メニューの中の項目を押す、外側を押す、もう一度開く、開いたままボタンを押す、の順に操作しました。

text
ボタンを押したあと: メニューが出ている
メニューの項目を押したあと: メニューが出ている
外側(左上から 400,300 の位置)を押したあと: メニューが出ていない
もう一度ボタンを押したあと: メニューが出ている
開いたままボタンを押したあと: メニューが出ていない
メニューのボタンを押すと開き、中の項目を押しても開いたまま、外側を押すと閉じて外側と判定した回数が1になる。もう一度開いてからボタンを押すと閉じるが、回数は1のまま
外側を押したときだけ判定の回数が増え、ボタンを押して閉じたときは増えない(状態の表示を足した検証用の画面)

開くボタンも内側に入れる

開くボタンを targets に入れず、メニューだけを内側にした版も試しました。メニューを開いたままボタンを押すと、閉じずに開いたままになります。

text
もう一度ボタンを押したあと: メニュー:開いている
開いたままボタンを押したあと: メニュー:開いている
呼ばれた順: toggle → outside → toggle → outside → toggle

最後の操作では、ボタンを押し下げた時点の pointerdown で、フックがボタンを外側と判定して閉じました。続いてマウスのボタンを離したときの click でボタンの onClick が動き、開いたり閉じたりを切り替えるので、また開きます。ボタンを内側に入れると、最後のボタンの操作は toggle だけになり、メニューは閉じました。

click ではなく pointerdown を待つ

フックの pointerdown を click に変えた版では、困ることが2つありました。

1つ目は、ボタンを内側に入れていないと、開いたクリックでそのまま閉じてしまうことです。

text
ボタンを押したあと: メニュー:閉じている
呼ばれた順: toggle → outside
document のリスナー: add click, remove click

開いたクリックで document に click のリスナーが付き、同じクリックで閉じました。React 18 の変更履歴には、クリックやキー入力で起きた更新のエフェクトは同期的に実行されると書かれています。ボタンの onClick で開いたあと、そのクリックの処理の中でエフェクトが動き、クリックが document に届く前にリスナーが付くので、こうなると考えられます。pointerdown を待つ版でもリスナーが付くのは同じクリックの処理の中ですが、その操作の pointerdown はもう終わっています。ボタンを内側に入れていない版でも、開いた操作では閉じませんでした(前の節の版で、ボタンを押したあとは「開いている」でした)。

2つ目は、メニューの中の文字の上でマウスのボタンを押し、外側まで動かしてから離すと閉じることです。文字を選ぼうとしただけでもメニューが消えます。

text
click: メニュー:閉じている
fixed: メニュー:開いている

1行目が click を待つ版、2行目がこの記事のフックです。MDN の click イベントのページによると、押した要素と離した要素が違うときの click は、両方を含むいちばん近い祖先の要素に届きます。その祖先はメニューの外の要素なので、click を待つ版では外側と判定されました。pointerdown なら、押した時点の要素で判定するので、メニューの中で押した操作は内側のままです。

開いているあいだだけリスナーを付ける

enabled に open を渡しているので、閉じているあいだは document にリスナーがありません。リスナーの登録と解除を数えると、開いたときに1回付き、開いたまま、フックを使うコンポーネントを5回描き直しても付け直さず、閉じたときに1回外れました。開発用のビルドで StrictMode で包んでも同じでした。

text
StrictMode あり(開発用のビルド)
  表示した直後: (なし)
  開いたあと: add pointerdown
  開いたまま 描き直し 5回 のあと: add pointerdown
  閉じたあと: add pointerdown, remove pointerdown

StrictMode では、開発時に限り、表示した直後にエフェクトのセットアップと片付けが1回余分に動きます(useEffect のリファレンスの注意事項)。メニューは表示した直後は閉じていて open が false なので、このときリスナーは付きません。比べるため、enabled を渡さず表示した直後から働かせる版を StrictMode で表示すると、付けて、外して、また付けていました。片付けで外しているので、リスナーが2つ残ることはありません。

text
表示した直後: add pointerdown, remove pointerdown, add pointerdown

ポータルの中は、DOM の上では外側になる

メニューの中に「色を選ぶ」ボタンを置き、色の一覧を createPortal で body に出しました。一覧の要素はメニューの要素の外にあるので、contains だけで判定する版では、一覧の色を押した瞬間にメニューが閉じます。閉じると一覧も消えるため、色は選ばれませんでした。

text
色の一覧の DOM の親: BODY/メニューの要素の中にあるか: false
「白」を押したあと: メニュー:閉じている/色:なし

一方、createPortal のリファレンスの注意事項にあるとおり、ポータルの中のイベントは、DOM ではなく React のツリーに沿って伝わります。そこで、メニューの要素の onPointerDownCapture で、押されたイベントを「内側で起きたもの」として覚えておき、document のリスナーで読む版を作りました。

ts
import { useEffect, useEffectEvent, useRef, type RefObject, type PointerEvent as ReactPointerEvent } from 'react';

// useClickOutside に、ポータルの中で押されたときを内側とみなす仕組みを足した版
export function useClickOutside(
  targets: RefObject<HTMLElement | null>[],
  onOutside: (event: PointerEvent) => void,
  enabled = true,
) {
  // React のツリーの中で押されたイベントを覚えておく
  const insideEvents = useRef(new WeakSet<Event>());

  const onPointerDown = useEffectEvent((event: PointerEvent) => {
    if (insideEvents.current.has(event)) return;
    const node = event.target as Node;
    if (targets.some((target) => target.current?.contains(node))) return;
    onOutside(event);
  });

  useEffect(() => {
    if (!enabled) return;
    document.addEventListener('pointerdown', onPointerDown);
    return () => document.removeEventListener('pointerdown', onPointerDown);
  }, [enabled]);

  // 内側の要素の onPointerDownCapture に渡す。ポータルの中で押されても呼ばれる
  return (event: ReactPointerEvent) => {
    insideEvents.current.add(event.nativeEvent);
  };
}

使う側では、返ってきた関数をメニューの要素に渡します。

tsx
const markInside = useClickOutside([menuRef, buttonRef], close, open);
// ...
<div ref={menuRef} className="menu" onPointerDownCapture={markInside}>

event.nativeEvent は、ブラウザのもとのイベントです(React のイベントオブジェクトの説明)。同じページには、React はイベントのハンドラをルートに付けていると書かれています。ルートの要素は document の内側にあるので、document のリスナーより先に React のハンドラが動き、印を付けたあとで判定できます。検証では、色の一覧で「白」を押してもメニューは開いたままで、色が選ばれました。

text
「白」を押したあと: メニュー:開いている/色:白
外側を押したあと: メニュー:閉じている/色:白

タッチ操作でも閉じる

MDN の pointerdown イベントのページのとおり、pointerdown はマウスのボタンを押したときにも、指が画面に触れたときにも起きます。タッチ対応の画面でタップしても、項目のタップでは開いたまま、外側のタップで閉じました。外側をタップしたときは、pointerdown が最初に届いています。

text
ボタンをタップしたあと: メニュー:開いている
メニューの項目をタップしたあと: メニュー:開いている
外側をタップしたあと: メニュー:閉じている
外側のタップで届いたイベント: pointerdown(touch) → touchstart → mousedown → click

作るときのポイント

外側のクリックで閉じるフックは、document の pointerdown で押された要素を見て、ref の要素の contains で内側かを判定します。開くボタンは内側に入れ、click ではなく pointerdown を待つと、開いたクリックで閉じる問題と、文字を選ぶ操作で閉じる問題を避けられます。ポータルで別の場所に出す部分があるときは、React のツリーに沿って届く onPointerDownCapture で内側の印を付けます。

メニューだけなら、ブラウザが外側のクリックと Esc キーで閉じてくれる Popover API も選べます。以前の記事「ReactでPopover APIを使い、メニューを表示する方法」で書きました。

​Posted by 86