Reactで、現在地を取得して表示する方法

​​React​Geolocation API,Playwright,StrictMode,TypeScript,useEffect,カスタムフック

ブラウザで現在地を取得するには、navigator.geolocation.getCurrentPosition() を呼びます。結果はコールバックで届くので、React では受け取った緯度・経度を state に入れて表示します。移動に合わせて表示を変えたいときは watchPosition() を使い、useEffect の片付けで止めます。

オプションを付けずに呼ぶと、位置が決まらないときに待ち続けることがあります。また、許可されなかったときと、HTTPS でないページで開いたときは、どちらも同じ PERMISSION_DENIED のエラーになります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。この環境には GPS などの位置を測る仕組みがないので、Playwright の機能で、緯度・経度を指定した偽の現在地を渡しました。位置情報の許可も Playwright で与えたもので、許可を求める画面の操作は試していません。実際の端末での精度や取得にかかる時間、enableHighAccuracy の効果は確かめていません。スマートフォンのブラウザや、Firefox、Safari でも試していません。

「現在地を取得」ボタンを2回押す。1回目は緯度 35.68120・経度 139.76710・精度 約20m、2回目は緯度 34.70250・経度 135.49590・精度 約35m が表示され、変わった値がピンクになる。右上に押した回数が出る
ボタンを押すたびに現在地を取り直す様子(カーソルと回数の表示は GIF 用に重ねたもの。2回目の前に偽の現在地を変えている)

ボタンを押して現在地を取得するコンポーネント

まず、geo.ts に、取得した結果を画面に出す形にする関数、エラーを画面に出す文に変える関数、位置情報を使えないページかどうかを調べる関数を用意します。

ts
// 位置情報の結果を、画面に出す形にする
export type Coords = { latitude: number; longitude: number; accuracy: number; timestamp: number };

export function toCoords(p: GeolocationPosition): Coords {
  const { latitude, longitude, accuracy } = p.coords;
  return { latitude, longitude, accuracy, timestamp: p.timestamp };
}

export function toMessage(err: GeolocationPositionError) {
  switch (err.code) {
    case err.PERMISSION_DENIED: return '位置情報の利用が許可されていません。ブラウザの設定で許可してください。';
    case err.POSITION_UNAVAILABLE: return '現在地を特定できませんでした。';
    case err.TIMEOUT: return '時間内に現在地を取得できませんでした。';
    default: return `現在地を取得できませんでした(${err.message})`;
  }
}

// HTTPS(または localhost)でないページでは、位置情報を使えない
export function unsupportedReason() {
  if (!window.isSecureContext) return 'このページでは位置情報を使えません(HTTPS で開いてください)。';
  if (!('geolocation' in navigator)) return 'このブラウザは位置情報に対応していません。';
  return '';
}

accuracy は、緯度・経度の精度をメートルで表した値です。MDN によると、この値は 95% の信頼度で示されています。timestamp は位置を取得した時刻で、ミリ秒の数値で届きます。

表示は CoordsView に分けました。緯度・経度は小数第5位までにそろえ、時刻は toLocaleTimeString() で時・分・秒にしています。

tsx
import type { Coords } from './geo';

export function CoordsView({ coords }: { coords: Coords }) {
  return (
    <dl className="coords">
      <dt>緯度</dt><dd>{coords.latitude.toFixed(5)}</dd>
      <dt>経度</dt><dd>{coords.longitude.toFixed(5)}</dd>
      <dt>精度</dt><dd>約{Math.round(coords.accuracy)}m</dd>
      <dt>取得時刻</dt><dd>{new Date(coords.timestamp).toLocaleTimeString('ja-JP')}</dd>
    </dl>
  );
}

CurrentLocation は、ボタンを押したときに getCurrentPosition() を呼びます。

tsx
import { useState } from 'react';
import { type Coords, toCoords, toMessage, unsupportedReason } from './geo';
import { CoordsView } from './CoordsView';

export function CurrentLocation() {
  const [coords, setCoords] = useState<Coords | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  function locate() {
    const reason = unsupportedReason();
    if (reason) return setError(reason);
    setLoading(true);
    setError('');
    navigator.geolocation.getCurrentPosition(
      (p) => {
        setCoords(toCoords(p));
        setLoading(false);
      },
      (err) => {
        setError(toMessage(err));
        setLoading(false);
      },
      // 10秒で諦める。前に取った位置は使わず、毎回取り直す
      { enableHighAccuracy: false, timeout: 10_000, maximumAge: 0 },
    );
  }

  return (
    <section>
      <button type="button" onClick={locate} disabled={loading}>
        {loading ? '取得中…' : '現在地を取得'}
      </button>
      <p role="alert">{error}</p>
      {coords && <CoordsView coords={coords} />}
    </section>
  );
}

getCurrentPosition() は Promise を返さず、成功したときのコールバックと失敗したときのコールバックを受け取ります。成功すると GeolocationPosition、失敗すると GeolocationPositionError が渡されます。

取得はボタンを押したときに始めています。MDN の Geolocation API の説明にあるとおり、まだ許可も拒否もされていなければ、呼んだ時点でブラウザが利用者に許可を求めるからです。

検証環境で、偽の現在地を緯度 35.6812、経度 139.7671、精度 20m にして「現在地を取得」を押したところ、「緯度 35.68120、経度 139.76710、精度 約20m」と取得時刻が表示されました。偽の現在地を緯度 34.7025、経度 135.4959、精度 35m に変えてもう一度押すと、「緯度 34.70250、経度 135.49590、精度 約35m」に変わりました。上の GIF は、同じ操作を撮ったものです。

timeout を付けないと、待ち続けることがある

getCurrentPosition() の3つ目の引数には、次の3つのオプションを渡せます。MDN によると、既定値は enableHighAccuracy が false、timeout が Infinity、maximumAge が 0 です。

オプション 意味
timeout 位置が届くまで待つ時間の上限(ミリ秒)
maximumAge 前に取った位置を、何ミリ秒前のものまで使ってよいか
enableHighAccuracy true にすると、できるだけ精度の高い位置を求める

timeout を付けないと、位置が決まらないあいだ、成功と失敗のどちらのコールバックも呼ばれません。検証環境で、位置情報は許可したうえで偽の現在地を渡さずに試したところ、15秒待ってもどちらも呼ばれませんでした。これでは「取得中…」の表示がいつまでも消えません。

CurrentLocation では timeout: 10_000 を渡しています。同じ状態で押すと、10.1秒後に失敗のコールバックが呼ばれ、TIMEOUT のときの文「時間内に現在地を取得できませんでした。」が表示されました。押した直後から失敗するまでのあいだは、ボタンが「取得中…」になり、押せない状態でした。

MDN の getCurrentPosition() の説明によると、enableHighAccuracy を true にすると、応答が遅くなったり、電力を多く使ったりすることがあります。検証環境では偽の現在地を使っているため、この違いは確かめていません。

maximumAge は、前に取った位置を使ってよい時間

maximumAge を 0 より大きくすると、その時間内に取った位置があれば、取り直さずにそれが返ることがあります。検証環境で、緯度 35.6812 の位置を取ったあとに、偽の現在地を緯度 34.7025 の位置へ変え、すぐにもう一度取得しました。

2回目の maximumAge 2回目に返った緯度
60000(1分) 35.6812(1回目に取った位置のまま)
0 34.7025(変えたあとの位置)

1分以内に取った位置があったので、60000 では前の位置がそのまま返りました。ボタンを押すたびに今の位置を知りたいなら、maximumAge は 0 のままにします。数分前の位置でもよいなら、maximumAge を大きくして、前に取った位置を使わせることもできます。

許可されなかったときと、HTTPS でないページで開いたとき

許可を与えずに押したときと、HTTPS でも localhost でもない http:// のページで押したときを試しました。どちらも code は 1(PERMISSION_DENIED)でした。

状況 エラーの code と message
位置情報の利用を許可しなかった 1 User denied Geolocation
HTTPS でも localhost でもない http:// のページ 1 Only secure origins are allowed (see: https://goo.gl/Y0ZkNV).

許可しなかった場合は、toMessage() が返す「位置情報の利用が許可されていません。ブラウザの設定で許可してください。」が表示されました。検証環境のヘッドレスの Chromium では、許可を求める画面の操作をしないまま断られました。断ったあとも、navigator.permissions.query() で調べた許可の状態は denied ではなく prompt のままでした。利用者が許可を求める画面で「ブロック」を選んだ場合の状態は確かめていません。

HTTPS でないページでも、検証環境の Chromium では navigator.geolocation そのものはありました。'geolocation' in navigator が true になるので、これだけでは使えるかどうかを判断できません。そのまま呼ぶと code が 1 のエラーになり、code だけを見ると「許可されていない」と表示してしまいます。

unsupportedReason() では、先に window.isSecureContext を調べています。検証環境では isSecureContext が false になり、「このページでは位置情報を使えません(HTTPS で開いてください)。」と表示されて、getCurrentPosition() は呼ばれませんでした。

MDN にあるとおり、Geolocation API は HTTPS や localhost のような secure context のページでしか使えません。開発用のサーバーを http://192.0.2.10:5173 のように IP アドレスやホスト名で開くと、localhost では動いていたページでも、許可したのにエラーになります(検証環境では、localhost 以外のホスト名で開いて確かめました)。

watchPosition() で位置を追い続ける

位置が変わるたびに表示を変えたいときは watchPosition() を使います。引数は getCurrentPosition() と同じです。戻り値の ID を clearWatch() に渡すと、追うのをやめます。React で使うために、useEffect の中で始めて、片付けで止めるカスタムフックを作りました。

ts
import { useEffect, useState } from 'react';
import { type Coords, toCoords, toMessage, unsupportedReason } from './geo';

// enabled が true の間だけ、位置が変わるたびに受け取る
export function useWatchPosition(enabled: boolean) {
  const [coords, setCoords] = useState<Coords | null>(null);
  const [error, setError] = useState('');

  useEffect(() => {
    if (!enabled) return;
    const reason = unsupportedReason();
    if (reason) {
      setError(reason);
      return;
    }
    const id = navigator.geolocation.watchPosition(
      (p) => {
        setCoords(toCoords(p));
        setError('');
      },
      // エラーのあとにも位置が届くことがあるので、最後の位置は消さない
      (err) => setError(toMessage(err)),
      { enableHighAccuracy: true, maximumAge: 0 },
    );
    return () => navigator.geolocation.clearWatch(id); // 追うのをやめる
  }, [enabled]);

  return { coords, error };
}

画面には、追うかどうかを切り替えるチェックボックスを置きました。

tsx
import { useState } from 'react';
import { useWatchPosition } from './useWatchPosition';
import { CoordsView } from './CoordsView';

export function LiveLocation() {
  const [watching, setWatching] = useState(false);
  const { coords, error } = useWatchPosition(watching);

  return (
    <section>
      <label>
        <input type="checkbox" checked={watching} onChange={(e) => setWatching(e.target.checked)} />
        現在地を追い続ける
      </label>
      <p role="alert">{error}</p>
      {coords && <CoordsView coords={coords} />}
    </section>
  );
}

検証環境でチェックを入れ、偽の現在地の緯度を 35.6812 から 35.6852、34.7025 へと変えました。そのたびに、表示の緯度・経度も変わりました。

このとき、コールバックが呼ばれた順は次のとおりでした。

text
位置 35.6812 → エラー 2 → 位置 35.6852 → エラー 2 → 位置 34.7025

偽の現在地を変えるたびに、新しい位置の前に POSITION_UNAVAILABLE(2)のエラーが1回ずつ届きました。偽の現在地の差し替え方によるものと考えられ、実際の端末で同じことが起きるかは確かめていません。ただ、MDN の Geolocation API の使い方の説明にも、watchPosition() の失敗のコールバックは何度も呼ばれることがあると書かれています。エラーが1回届いても追跡は終わらず、そのあとに位置が届くこともあります。そのため useWatchPosition では、エラーのときに最後の位置を消さず、次の位置が届いたらエラーの表示を消しています。

検証では、watchPosition() と clearWatch() の呼び出しを記録して、止められていない watch の数を数えました。チェックを外すと片付けで clearWatch() が呼ばれ、止められていない watch の数は 0 になりました。その後に偽の現在地を変えても、コールバックは呼ばれませんでした。画面には最後に受け取った位置(緯度 34.70250)が残りました。本番用ビルドでも、ボタンでの取得とチェックボックスでの追跡は開発用ビルドと同じ結果でした。

clearWatch() を忘れると、外したあとも位置を受け取り続ける

比べるために、片付けを書かない次のコンポーネントも試しました。

tsx
import { useEffect, useState } from 'react';

// 比べるための書き方:片付けで clearWatch() を呼んでいない
export function NaiveWatch() {
  const [lat, setLat] = useState<number | null>(null);
  useEffect(() => {
    navigator.geolocation.watchPosition((p) => setLat(p.coords.latitude));
  }, []);
  return <p>{lat}</p>;
}

開発用ビルドの StrictMode では、React の公式リファレンスにある StrictMode の説明のとおり、Effect の実行と片付けがもう1回ずつ余分に行われます。そのため、どちらの書き方でも watchPosition() は2回呼ばれます。表示した直後から追い始める形で useWatchPosition(true) を使ったコンポーネントと比べました。この比較は、開発用ビルドだけで試しました。

書き方 表示したあとの watch の数 外したあとの watch の数 外したあとに現在地を変えたとき
NaiveWatch(片付けなし) 2 2 2つの watch がどちらも位置を受け取った
useWatchPosition(片付けで clearWatch()) 1 0 何も受け取らなかった

NaiveWatch では、1回目の Effect で始めた watch が止まらずに残り、コンポーネントを外したあとも2つとも動き続けました。外したコンポーネントの state を変えようとしても画面には何も出ませんが、ブラウザは位置を受け取り続けます。useWatchPosition では余分な1つ目が片付けで止まり、外すと残りも止まりました。

​Posted by 86