これは MIERUNE Advent Calendar 2025 の17日目の記事です。
昨日は @natsukino8 さんによる QGISをお揃いのUIにする でした。
はじめに
地図の状態をURLと同期させる手法は様々ありますが、今回はTanStack Routerでの実装例になります。メインのフレームワークはReactを使います。
ルーティングの機能が欲しいだけのためにNext.jsを使うのではなく、その場合はルーティングのライブラリーだけ使えば良い、というのを実践してみました。
SvelteKitの例は過去に作りましたので、そちらを参照ください。
作ったもの
地図を動かすと、中心座標が表示されます。URLのクエリパラメーターも変化しますので、ブラウザの更新をしても最後の状態が保持されています。
作り方
前提としてNode.jsはインストールされているものとします。
プロジェクト作成とライブラリのインストール
Vite で React + TypeScript のプロジェクトを作成し、必要なライブラリを入れます。
# プロジェクト作成
npm create vite@latest my-map-app -- --template react-ts
cd my-map-app
# 依存関係のインストール
# zod: URLパラメータのバリデーション用
# maplibre-gl: 地図表示用
npm install @tanstack/react-router maplibre-gl zod
# 開発用ライブラリ(Viteプラグイン、DevTools、CLI)
npm install -D @tanstack/router-plugin @tanstack/router-devtools @tanstack/router-cli
Viteの設定
プラグインを設定します。
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter(), // react() より前に記述するのがポイント
react(),
],
})
初回のルート生成
ここがハマりポイントです。routeTree.gen.ts(ルートの構造定義ファイル)は自動生成されますが、初回はまだ存在しないため、手動で生成コマンドを叩く必要があります。
まず、今後のために package.json の scripts を修正しておきます。
{
"scripts": {
"dev": "vite",
"build": "tsr generate && tsc -b && vite build", // ビルド前に生成
"lint": "eslint .",
"preview": "vite preview",
"generate": "tsr generate" // 手動実行用のコマンドを追加
}
}
修正したら、ターミナルで一度実行します。
npm run generate
これで src/routeTree.gen.ts が生成されます。まだルートファイルを作っていないので中身は空に近いですが、これでエラーが出なくなります。
アプリのエントリーポイント
生成された routeTree を使ってルーターを初期化します。
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' // さっき生成されたファイル
const router = createRouter({ routeTree })
// TypeScriptの型補完を効かせるための設定
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)
ルートレイアウトの作成
全ページ共通のレイアウト定義です。
src/routes フォルダを作成し、その中に __root.tsx を作成してください。
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<>
<Outlet />
</>
),
})
地図ページの実装
ここがメインの実装です。URLパラメータと地図を同期させます。
src/routes/index.tsx を作成してください。
import { createFileRoute } from '@tanstack/react-router'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useEffect, useRef } from 'react'
import { z } from 'zod'
import { CrosshairIcon } from '../components/CrosshairIcon'
import { CoordinateOverlay } from '../components/CoordinateOverlayProps'
// クエリパラメータの定義(バリデーションとデフォルト値)
const mapSearchSchema = z.object({
lat: z.number().catch(35.6812), // 東京駅
lng: z.number().catch(139.7671),
zoom: z.number().catch(12),
bearing: z.number().catch(0),
pitch: z.number().catch(0),
})
export const Route = createFileRoute('/')({
validateSearch: (search: Record<string, unknown>) => mapSearchSchema.parse(search),
component: MapComponent,
})
function MapComponent() {
const mapContainer = useRef<HTMLDivElement>(null)
const mapInstance = useRef<maplibregl.Map | null>(null)
// URLから状態を取得 (型安全なところがポイント)
const { lat, lng, zoom, bearing, pitch } = Route.useSearch()
const navigate = Route.useNavigate()
useEffect(() => {
if (!mapContainer.current || mapInstance.current) return
mapInstance.current = new maplibregl.Map({
container: mapContainer.current,
style: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
center: [lng, lat],
zoom: zoom,
bearing: bearing,
pitch: pitch
})
const map = mapInstance.current
// 地図が動いたらURLを更新
map.on('moveend', () => {
const center = map.getCenter()
const newZoom = map.getZoom()
const newBearing = map.getBearing()
const newPitch = map.getPitch()
navigate({
search: (prev: { lat: number; lng: number; zoom: number }) => ({
...prev,
lat: parseFloat(center.lat.toFixed(6)),
lng: parseFloat(center.lng.toFixed(6)),
zoom: parseFloat(newZoom.toFixed(2)),
bearing: parseFloat(newBearing.toFixed(2)),
pitch: parseFloat(newPitch.toFixed(2)),
}),
replace: true, // 戻るボタンの履歴を汚さないようにする
})
})
return () => {
map.remove()
mapInstance.current = null
}
}, [])
return (
<div style={{ position: 'relative', width: '100vw', height: '100vh' }}>
<div ref={mapContainer} style={{ width: '100%', height: '100%' }} />
<CrosshairIcon />
<CoordinateOverlay lat={lat} lng={lng} />
</div>
)
}
実行
サーバーを立ち上げます。
npm run dev
ポイント
URLを「信頼できる唯一の情報源」にする
今回の実装では、ステート(状態)の実体を URL そのものに委ねています。
これにより、「URLさえ見れば、アプリの状態が完全に再現できる」 状態になります。
validateSearchとZodによる堅牢性
URLパラメータはすべて文字列です。「?lat=35.6」も「?lat=あいうえお」も、受け取る側からすればただの文字です。
TanStack Routerの validateSearch を使うと、コンポーネントに届く前段階でデータをチェクできます。
これにより、コンポーネント側では lat が 「絶対に数値(number)である」 と保証されます。if (isNaN(lat)) などのチェックコードは不要になります。
URLの履歴を汚さない
Maps 関数には履歴を追加する push(デフォルト)と、現在の履歴を書き換える replace があります。
- pushの場合:地図を動かすたびに「戻るボタン」の履歴が溜まります
- replace: trueの場合:履歴は増えません。今回は履歴を溜めたくないのでこちらの設定にしています
コラム:今回の実装にファイルベースルーティングは必須?
「地図1枚出すだけなら、従来のコード定義(Code-Based)でも良いのでは?」と思った方もいるでしょう。その通りです。単機能アプリなら Code-Based の方がシンプルです。
しかし、今回はあえて File-Based Routing を採用しました。
将来的に「設定ページ」や「詳細ページ」を足したくなった時、ファイルを追加するだけで型安全に拡張できるなど、TanStack Routerの強力な機能が使えるということもあり、今回はこの構成にしています。
