Reactでユーザーフレンドリーなモバイルメニューを実装する方法

今回はReactを使って、ハンバーガーメニューをクリックした時だけでなく、メニュー外の部分をクリックした時にも自動的に閉じる機能を実装しました。この記事では、特にuseRefフックの使い方にフォーカスして解説します。

実装の目的

モバイルサイトでよく見かけるハンバーガーメニュー。一般的には、メニューボタンをクリックすると開き、もう一度ボタンをクリックすると閉じる仕組みになっています。しかし、ユーザー体験を向上させるためには、「メニュー以外の場所をクリックしても閉じる」機能があると便利です。

主要な技術要素

実装には以下のReactフックを使用しています。

  • useState: メニューの開閉状態を管理
  • useRef: DOM要素を参照するために使用
  • useEffect: クリックイベントの設定と解除

コードの仕組み

1. 必要な状態と参照の設定

まず、メニューの状態を管理するための変数と、DOM要素を参照するためのuseRefを設定します。

// メニューの状態を管理
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
const [animating, setAnimating] = useState(false);

// DOM要素への参照
const mobileMenuRef = useRef<HTMLDivElement>(null);
const mobileButtonRef = useRef<HTMLButtonElement>(null);

ここでは3つの状態変数を使っています。

  • mobileMenuOpen: メニューが開いているかどうかの論理値
  • menuVisible: メニューがDOM上に存在するかどうかの論理値(アニメーション用)
  • animating: アニメーション中かどうかの論理値

また、useRefを使って2つのDOM要素への参照を作成しています。

  • mobileMenuRef: メニュー本体への参照
  • mobileButtonRef: ハンバーガーボタンへの参照

2. メニューの開閉アニメーションの制御

メニューの開閉状態が変わった時のアニメーション処理:

useEffect(() => {
    if (mobileMenuOpen) {
        // メニューを開く処理
        setMenuVisible(true);
        setAnimating(true);
        setTimeout(() => setAnimating(false), 300);
    } else if (menuVisible) {
        // メニューを閉じる処理
        setAnimating(true);
        setTimeout(() => {
            setMenuVisible(false);
            setAnimating(false);
        }, 300);
    }
}, [mobileMenuOpen]);

このuseEffectはmobileMenuOpenの値が変わるたびに実行されます:

  • メニューを開く時:まずmenuVisibleをtrueにしてDOM上に表示し、アニメーション開始
  • メニューを閉じる時:アニメーションを開始し、300ms後にDOM上から削除

3. メニュー外クリックの検出

ここが今回の実装の核となる部分です。

useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
        // ボタン自体のクリックは無視(トグル動作は別のハンドラで処理)
        if (
            mobileMenuRef.current && 
            !mobileMenuRef.current.contains(event.target as Node) &&
            mobileButtonRef.current &&
            !mobileButtonRef.current.contains(event.target as Node) &&
            menuVisible &&
            !animating
        ) {
            setMobileMenuOpen(false);
        }
    };

    document.addEventListener("mousedown", handleClickOutside);
    return () => {
        document.removeEventListener("mousedown", handleClickOutside);
    };
}, [menuVisible, animating]);

この処理の流れを詳しく見ていきましょう。

  1. document全体に対してmousedownイベントリスナーを設定
  2. クリックが発生したときにhandleClickOutside関数が呼ばれる
  3. この関数内で複数の条件を確認:
  • mobileMenuRef.currentが存在する(メニューがレンダリングされている)
  • クリックされた要素がメニュー内部ではない
  • mobileButtonRef.currentが存在する(ボタンがレンダリングされている)
  • クリックされた要素がボタン自体ではない
  • メニューが表示中である
  • アニメーション中ではない
  1. すべての条件を満たせば、メニューを閉じる指示を出す

特に重要なのは、useRefを使ってDOM要素を参照し、containsメソッドでクリックされた要素がメニュー内部かボタン自体かを判定している点です。

4. DOM要素への参照の設定

最後に、作成したuseRef参照をJSX内の実際のDOM要素に紐付けます。

<button
    className="l-header__mobile-button"
    onClick={toggleMobileMenu}
    aria-label="メニューを開く"
    ref={mobileButtonRef}  // ここでボタン要素を参照
>
    {/* ボタンの中身 */}
</button>

{menuVisible && (
    <div
        className={`l-header__mobile-menu ${
            mobileMenuOpen ? "menu-fade-in" : "menu-fade-out"
        }`}
        ref={mobileMenuRef}  // ここでメニュー要素を参照
    >
        {/* メニューの中身 */}
    </div>
)}

ref属性にuseRefで作成した参照を指定することで、React側からそのDOM要素にアクセスできるようになります。

useRefとは「名札」のようなもの

useRefは、Reactで特定のHTML要素(DOMノード)に「名札」をつけるような機能です。この「名札」があれば、後からその要素を見つけたり、操作したりすることができます

useRefを具体例で理解する

例えば、教室に30人の生徒がいるとします。先生が「窓側の3番目に座っている人」と言うより、「田中さん」と名前で呼んだ方が簡単ですよね。useRefはまさにそのような「名前」の役割を果たします。

コードで表すと、、、

// 「田中さん」という名札を用意する
const 田中さんRef = useRef(null);

// 実際の生徒(HTML要素)に名札をつける
<div ref={田中さんRef}>田中</div>

// これで後から「田中さん」を見つけることができる
console.log(田中さんRef.current); // この生徒(div要素)を取得

実際のメニュー実装での使い方

今回のモバイルメニューの例では、

// メニューとボタンの名札を用意
const mobileMenuRef = useRef(null);
const mobileButtonRef = useRef(null);

// 実際の要素に名札をつける
<button ref={mobileButtonRef}>メニュー</button>
<div ref={mobileMenuRef}>メニューの内容</div>

ここで重要なのは、mobileMenuRef.currentと書くと、実際のHTML要素(このケースでは<div>)そのものにアクセスできるという点です。

なぜ「保持」と言うのか?

Reactでは通常、コンポーネントが再レンダリングされると変数がリセットされますが、useRefで作った参照(名札)はリセットされません。つまり:

  1. 最初のレンダリングで「名札」を特定の要素につける
  2. コンポーネントが再レンダリングされても
  3. その「名札」は同じ要素を指し続ける

これが「参照を保持する」という意味です。

具体的な使い道

特にDOM操作が必要な場合に便利です:

  1. 要素の測定:要素の高さや幅を取得する
  2. フォーカス制御:特定の入力欄に自動でフォーカスを当てる
  3. クリック判定:今回のように「この要素の内側がクリックされたかどうか」を判断する

今回のコードでの具体的な流れ

①mobileMenuRefとmobileButtonRefという2つの「名札」を用意

②それぞれをメニュー本体とメニューボタンの要素に付ける

③画面上のどこかがクリックされた時:

    • mobileMenuRef.current.contains(event.target)で「クリックされた場所はメニュー内?」を判定
    • mobileButtonRef.current.contains(event.target)で「クリックされた場所はボタン?」を判定

    ④どちらでもない場合はメニュー外のクリックと判断してメニューを閉じる

      まとめるとこんなイメージ

      useRefは「これが〇〇だよ」と名前をつける仕組み

      その名前を使って、後からその要素を見つけられる

      特にReactで普通に書けない複雑なDOM操作ができるようになる

      まとめ

      今回の実装では、useRefを使ってDOM要素を参照することで、「メニュー外をクリックすると閉じる」という直感的な操作を実現しました。これによりユーザー体験が向上し、より使いやすいモバイルメニューが実装できました。特に以下の点がポイントです:

      1. useRefでDOM要素への参照を保持
      2. containsメソッドでクリック位置を判定
      3. 複数の条件を組み合わせて適切なタイミングでメニューを閉じる
      4. アニメーション中の誤操作を防止

      この実装パターンは、ドロップダウンメニューやモーダルウィンドウなど、他のUI要素にも応用できる汎用的なテクニックです。React開発では、このような小さな工夫の積み重ねが、全体的なユーザー体験の向上につながります。ぜひ皆さんのプロジェクトにも取り入れてみてください!