宅建士の業務ハック

紙とFAXの残る不動産業界で、現役宅建士がコードを書きながら、日々の仕事を少しずつ楽にしていく

カスタムフックで「どこで何が動くか」を整理——useIsMobile / useGeolocate / useCloseAll の設計

はじめに

しまなみ海道観光マップのデスクトップ表示(左)とモバイル表示(右)。useIsMobileでレイアウトを切り替えている
左がデスクトップ、右がモバイルの表示です。ナビゲーションの構成やボタンの配置が変わります。

React を書いていると、コンポーネントの中にロジックが増えてきます。useEffect が2つ、useState が3つ、コールバックが4つ……気づいたら何をしているファイルなのかわからなくなっていました。

カスタムフックはこの問題を解決する手段です。「どこで何が動くか」をフックという単位に切り出すことで、コンポーネントは「何を表示するか」だけに集中できます。

ただ、カスタムフックを「なぜ作るか」は一つではありませんでした。本サイトを作りながら、目的が全然違う3種類のカスタムフックが生まれました。


useIsMobile——ブラウザAPIをラップするパターン

なぜ作ったか

地図画面はモバイルとデスクトップで表示が大きく変わります。パネルの開き方、ドロワーの有無、ボタンの配置——これらをJavaScriptで制御する必要がありました。

CSSの @media だけでは足りない場面があります。「モバイルかどうか」をコンポーネントの中で if で分岐したいとき、window.matchMedia を直接書くと毎回SSR対応・イベントリスナーの登録・クリーンアップが必要になります。これをまとめたのが useIsMobile です。

コード

// src/hooks/useIsMobile.ts
import { useEffect, useState } from "react";

export function useMediaQuery(query: string) {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    if (typeof window === "undefined") return; // SSR対策
    const mql = window.matchMedia(query);
    const onChange = () => setMatches(mql.matches);
    onChange(); // 初回判定
    mql.addEventListener?.("change", onChange);
    return () => mql.removeEventListener?.("change", onChange);
  }, [query]);

  return matches;
}

/** モバイル判定。デフォルト: max-width 768px */
export function useIsMobile(maxWidth = 768) {
  return useMediaQuery(`(max-width: ${maxWidth}px)`);
}

ポイント

useMediaQueryuseIsMobile を2段構えにした理由

useMediaQuery は汎用的な「メディアクエリをstateにする」フックです。useIsMobile はそれに「768px以下をモバイルとみなす」というこのプロジェクトのルールを乗せた薄いラッパーです。将来「タブレット判定(1024px以下)も欲しい」となったとき、useMediaQuery を再利用すれば済みます。

typeof window === "undefined" のチェック

Next.js はサーバーサイドでコンポーネントを実行するため、window が存在しない環境で window.matchMedia を呼ぶとエラーになります。useEffect の中でチェックすることで、サーバーでは何もせずスキップします。

初期値が false の理由

useState(false) で始まるのは「最初はモバイルでないとみなす」という選択です。useEffect は初回レンダリングの後に走るため、サーバーとクライアントで初期値が一致している必要があります。window が存在しないサーバーでは「モバイルかどうか不明」なので false が安全な初期値です。

使い方

// Header.tsx・RightPane.tsx での実際の使い方
// ブレークポイントは引数で渡せる。1025px以下をモバイルとみなす
const isMobile = useIsMobile(1025);

return isMobile ? <MobileLayout /> : <DesktopLayout />;

コンポーネントの中はこれだけです。window.matchMedia のことは何も知らなくていいです。ブレークポイントは引数で渡せるので、場面に応じて変えられます。


useGeolocate——コールバックとstateをセットにするパターン

なぜ作ったか

本サイトの地図では、ユーザーが「現在地ボタン」を押すと自分の位置を地図上にピンで表示します。この機能はMapboxの GeolocateControl というコントロールを使っています。

GeolocateControl は「位置情報が取れたとき」と「エラーが起きたとき」のコールバックを受け取ります。このコールバックの中でstateを更新する必要があります。

  • 位置情報取得成功 → myPosmyAccuracy を更新 → 地図をその位置にフォーカス
  • 位置情報取得失敗 → errMsg を更新 → エラーを表示

この「コールバック+それに対応するstate」がセットになったのが useGeolocate です。

コード

// src/hooks/useGeolocate.ts
import { useCallback, useState } from "react";

export function useGeolocate(opts?: {
  onFocus?: (loc: { lng: number; lat: number }, accuracy?: number) => void;
}) {
  const [myPos, setMyPos] = useState<{ lng: number; lat: number }>();
  const [myAccuracy, setMyAccuracy] = useState<number | undefined>();
  const [errMsg, setErrMsg] = useState<string | null>(null);

  const onError = useCallback((e: GeolocationPositionError) => {
    setErrMsg(e.message ?? "現在地を取得できませんでした");
  }, []);

  const onGeolocate = useCallback(
    (pos: GeolocationPosition) => {
      const { latitude, longitude, accuracy } = pos.coords;
      setErrMsg(null);
      const loc = { lng: longitude, lat: latitude };
      setMyPos(loc);
      setMyAccuracy(accuracy);
      opts?.onFocus?.(loc, accuracy); // 地図をその位置にフォーカスする(外から渡す)
    },
    [opts]
  );

  return { myPos, myAccuracy, errMsg, setErrMsg, onError, onGeolocate };
}

ポイント

navigator.geolocation を直接呼ばない

このフックは navigator.geolocation.getCurrentPosition() を自分では呼びません。Mapboxの GeolocateControl が位置情報の取得を担い、結果をコールバックとして渡してきます。このフックは「結果を受け取ったらどう処理するか」だけを持っています。

useCallback を使う理由

onGeolocateonErroruseCallback で包んでいるのは、これらをMapboxのイベントリスナーに渡すためです。関数が毎回再生成されると、リスナーの付け替えが無駄に走ります。

opts?.onFocus——地図フォーカスは外から渡す

位置情報が取れたとき、地図をその座標にアニメーションで移動させたいです。しかし地図のインスタンス(Mapboxの map オブジェクト)はこのフックの外にあります。「地図を動かす処理」を onFocus として外から受け取ることで、フック自体は地図のことを知らなくて済みます。

使い方

const mapRef = useRef<mapboxgl.Map | null>(null);

const { myPos, myAccuracy, errMsg, onError, onGeolocate } = useGeolocate({
  onFocus: (loc) => {
    mapRef.current?.flyTo({ center: [loc.lng, loc.lat], zoom: 15 });
  },
});

// react-map-gl の GeolocateControl に props として渡す(PowerMap.tsx)
<GeolocateControl
  onError={onError}
  onGeolocate={onGeolocate}
/>

useCloseAll——複数storeの横断操作をまとめるパターン

なぜ作ったか

本サイトの地図画面には4種類のレイヤーがあります。ホテル・グルメ・観光スポット・バス停です。それぞれ選択状態をZustandのstoreで管理しています。

地図の背景をタップしたとき、開いているパネルをすべて閉じる必要があります。最初はコンポーネントの中に直接書いていました。

// 最初はコンポーネントの中にこれを直接書いていた
setSelectedHotelNo(null);
setSelectedSpot(null);
setSelectedRestaurant(null);
setSelectedBusStop(null);
// さらにMapboxのpopupも閉じる...

「閉じる」という操作が4箇所以上に散らばっていて、1つ追加するたびに全部の呼び出し元を直さなければなりませんでした。これを1つにまとめたのが useCloseAll です。

コード

// src/hooks/useCloseAll.ts
"use client";

import { useCallback } from "react";
import { useHotelStore } from "@/store/useHotelStore";
import { useSpotStore } from "@/store/useSpotStore";
import { useRestaurantStore } from "@/store/useRestaurantStore";
import { useBusStore } from "@/store/useBusStore";

export function useCloseAll() {
  return useCallback(() => {
    // ホテルを完全リセット
    const hs = useHotelStore.getState();
    hs.closeDetail?.();
    hs.clearSelectedHotel?.();
    hs.setSelectedHotelNo?.(null);
    useHotelStore.setState({ selectedHotelNo: null, isPaneOpen: false, isSheetOpen: false });

    // 観光スポットを完全クリア
    const ss = useSpotStore.getState();
    ss.clearSelectedSpot?.();
    ss.closeSpotPane?.();
    useSpotStore.setState?.({ selectedSpot: null, isPaneOpen: false });

    // グルメを完全クリア
    const rs = useRestaurantStore.getState();
    rs.clearSelectedRestaurant?.();
    useRestaurantStore.setState?.({ selectedRestaurant: null });

    // バス停を完全クリア
    const bs = useBusStore.getState();
    bs.clearSelectedBusStop?.();
    useBusStore.setState?.({ selectedBusStop: null });

    // MapboxのローカルPopupも閉じる(storeの外にあるため)
    if (typeof window !== "undefined") {
      window.dispatchEvent(new Event("map:close-all-popups"));
    }
  }, []);
}

※実際のコードは store 側の API が複数形式に対応するため型まわりが複雑ですが、やっていることはこれがすべてです。

ポイント

store.getState() でReactの外からstateを読む

Zustandは useHotelStore.getState() でReactのレンダリングサイクルの外からstateを読み書きできます。useCallback の中でstoreの値を操作するときに使います。

storeのメソッド + setState の二重呼び出し

hs.closeDetail?.()useHotelStore.setState(...) を両方呼んでいます。「storeが提供しているメソッドを優先して呼び、さらに直接stateも上書きする」という念押しです。storeのAPIが途中で変わったときも、どちらかは効くようにするための防御的なコードです。

window.dispatchEvent でMapboxのPopupを閉じる

Mapboxの地図上に表示しているpopupは、Reactのstoreの外で管理されています。storeをいくら更新してもpopupは消えません。window.dispatchEvent(new Event("map:close-all-popups")) でカスタムイベントを発火し、地図コンポーネント側で addEventListener("map:close-all-popups", ...) でキャッチして閉じるようにしています。

使い方

const closeAll = useCloseAll();

// ロゴをクリックしてホームに戻るとき(Header.tsx)
const handleLogoClick = () => {
  closeAll();
};

// 閉じるボタンを押したとき(RightPane.tsx・RestaurantDetailView.tsx)
<button onClick={closeAll}>閉じる</button>

どこから呼んでも closeAll 一発で全部閉じます。


3つを並べてわかったこと

フック 切り出した理由
useIsMobile ブラウザAPIの扱いを毎回書きたくなかった
useGeolocate コールバックとstateがセットで動くので一緒にしたかった
useCloseAll 複数の場所に散らばった操作を1箇所にまとめたかった

「カスタムフックを作る理由」は一つではありませんでした。ブラウザAPIのラップ、関連するコールバックとstateの束、横断的な操作のまとめ——目的が違えば、フックの形も変わります。

共通しているのは「コンポーネントに書き続けると辛くなった」という経験から生まれていることです。最初から設計したわけではなく、コンポーネントが重くなってから切り出しました。


本サイト(Next.js + TypeScript + MDX)を作りながら学んでいます。

mojitonews.hateblo.jp

mojitonews.hateblo.jp

mojitonews.hateblo.jp