Reactハンズオン「天気予報アプリ」
2024-2025 Shunsuke Watanabe
このハンズオンでは天気予報アプリを作ります。このハンズオンはReact入門チュートリアルの内容を前提にしています。
このアプリの作成を通じて以下の知識の確認をします。
- TanStack Query
- TanStack Router
1. プロジェクト初期化
npm create vite@latest weather-appcd weather-appnpm install
必要なライブラリのインストール
npm install @tanstack/react-router @tanstack/react-query zod @tanstack/zod-adapternpm install -D tailwindcss @tailwindcss/vite daisyui @tanstack/router-plugin @tanstack/router-devtools @tanstack/eslint-plugin-router
Viteの設定変更
-
vite.config.ts
に tailwindcssプラグインを追加import { defineConfig } from 'vite'import react from '@vitejs/plugin-react-swc'import tailwindcss from '@tailwindcss/vite'// https://vitejs.dev/config/export default defineConfig({plugins: [react(), tailwindcss()],}) -
index.css
で TailwindCSSとDaisyUIを読み込み@import "tailwindcss";@plugin "daisyui" {themes: cupcake --default;} -
vite.config.ts
に TanStack Routerの設定を追加import { defineConfig } from 'vite'import react from '@vitejs/plugin-react-swc'import tailwindcss from '@tailwindcss/vite'import { TanStackRouterVite } from "@tanstack/router-plugin/vite";// https://vitejs.dev/config/export default defineConfig({plugins: [react(), tailwindcss(), TanStackRouterVite()],})
開発用サーバ起動
npm run dev
2. ルートの追加
src/routes/__root.tsx
を作成src/routes/index.tsx
を作成App.tsx
で TanStack Routerを読み込み
import "./App.css";import { RouterProvider, createRouter } from "@tanstack/react-router";import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" { interface Register { router: typeof router; }}
function App() { return ( <RouterProvider router={router} /> );}
export default App;
3. 都市一覧ページ作成
src/routes/index.tsx
に都市名とその天気のリンクを表示します
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: RouteComponent,});
const cities: Record<string, string> = { 稚内: "011000",旭川: "012010",留萌: "012020",網走: "013010",北見: "013020",紋別: "013030",根室: "014010",釧路: "014020",帯広: "014030",室蘭: "015010",浦河: "015020",札幌: "016010",岩見沢: "016020",倶知安: "016030",函館: "017010",江差: "017020",青森: "020010",むつ: "020020",八戸: "020030",盛岡: "030010",宮古: "030020",大船渡: "030030",仙台: "040010",白石: "040020",秋田: "050010",横手: "050020",山形: "060010",米沢: "060020",酒田: "060030",新庄: "060040",福島: "070010",小名浜: "070020",若松: "070030",水戸: "080010",土浦: "080020",宇都宮: "090010",大田原: "090020",前橋: "100010",みなかみ: "100020",さいたま: "110010",熊谷: "110020",秩父: "110030",千葉: "120010",銚子: "120020",館山: "120030",東京: "130010",大島: "130020",八丈島: "130030",父島: "130040",横浜: "140010",小田原: "140020",新潟: "150010",長岡: "150020",高田: "150030",相川: "150040",富山: "160010",伏木: "160020",金沢: "170010",輪島: "170020",福井: "180010",敦賀: "180020",甲府: "190010",河口湖: "190020",長野: "200010",松本: "200020",飯田: "200030",岐阜: "210010",高山: "210020",静岡: "220010",網代: "220020",三島: "220030",浜松: "220040",名古屋: "230010",豊橋: "230020",津: "240010",尾鷲: "240020",大津: "250010",彦根: "250020",京都: "260010",舞鶴: "260020",大阪: "270000",神戸: "280010",豊岡: "280020",奈良: "290010",風屋: "290020",和歌山: "300010",潮岬: "300020",鳥取: "310010",米子: "310020",松江: "320010",浜田: "320020",西郷: "320030",岡山: "330010",津山: "330020",広島: "340010",庄原: "340020",下関: "350010",山口: "350020",柳井: "350030",萩: "350040",徳島: "360010",日和佐: "360020",高松: "370000",松山: "380010",新居浜: "380020",宇和島: "380030",高知: "390010",室戸岬: "390020",清水: "390030",福岡: "400010",八幡: "400020",飯塚: "400030",久留米: "400040",佐賀: "410010",伊万里: "410020",長崎: "420010",佐世保: "420020",厳原: "420030",福江: "420040",熊本: "430010",阿蘇乙姫: "430020",牛深: "430030",人吉: "430040",大分: "440010",中津: "440020",日田: "440030",佐伯: "440040",宮崎: "450010",延岡: "450020",都城: "450030",高千穂: "450040",鹿児島: "460010",鹿屋: "460020",種子島: "460030",名瀬: "460040",那覇: "471010",名護: "471020",久米島: "471030",南大東: "472000",宮古島: "473000",石垣島: "474010",与那国島: "474020",};
function RouteComponent() { return ( <> <h1 className="mb-4 text-xl">日本各地の天気</h1> {Object.keys(cities).map((key) => ( <p key={key}> <Link to="/weather" search={{ cityId: cities[key], }} > {key} </Link> </p> ))} </> );}
4. 天気表示ページの作成
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: WeatherSearch,});
function WeatherSearch() { return ( <div className="p-4 max-w-md mx-auto"> 天気情報 </div> );}
5. 天気データの取得
routes/__root.ts
を開き、コンテクスト付きルートに変更
import * as React from "react"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import type { QueryClient } from "@tanstack/react-query";
type RouterContext = { queryClient: QueryClient; };
export const Route = createRootRouteWithContext<RouterContext>()({ component: RootComponent, });
function RootComponent() { return ( <React.Fragment> <Outlet /> </React.Fragment> ); }
App.tsx
でクエリークライアントを初期化してコンテクストにセット
import "./App.css"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; import { QueryClient } from "@tanstack/react-query";
const router = createRouter({ routeTree, context: { queryClient: new QueryClient(), }, });
declare module "@tanstack/react-router" { interface Register { router: typeof router; } }
function App() { return <RouterProvider router={router} />; }
export default App;
src/api/weather.ts
を作成しAPI呼び出しコードを追加
利用しているAPIは個人の方が運営しているものなので、過度なアクセスは控えましょう
import { queryOptions } from "@tanstack/react-query"; const API_URL = "https://weather.tsukumijima.net/api/forecast/city";
const getWeather = (cityId: string) => async () => { if (!cityId) { throw new Error("都市IDを入力してください"); } const res = await fetch(`${API_URL}/${cityId}`); if (!res.ok) { throw new Error(res.statusText); } return res.json(); };
export const WeatherQueryOption = (cityId: string) => queryOptions({ queryKey: [cityId], queryFn: getWeather(cityId), })
src/routes/weather.tsx
のloaderで クエリを呼び出し
import { createFileRoute } from "@tanstack/react-router";import { WeatherQueryOption } from "../api/weather";import { zodValidator } from "@tanstack/zod-adapter";import { z } from "zod";
const weatherSearchParamsSchema = z.object({ cityId: z.string(),});
export const Route = createFileRoute("/weather")({ validateSearch: zodValidator(weatherSearchParamsSchema), loaderDeps: ({ search }) => ({ cityId: search.cityId }), loader: async ({ context, deps }) => await context.queryClient.ensureQueryData(WeatherQueryOption(deps.cityId)), component: RouteComponent,});
function RouteComponent() { return <div>Hello "/weather"!</div>;}
6. 天気データの表示
import { createFileRoute } from "@tanstack/react-router";import { WeatherQueryOption } from "../api/weather";import { zodValidator } from "@tanstack/zod-adapter";import { z } from "zod";
const weatherSearchParamsSchema = z.object({ cityId: z.string(),});
export const Route = createFileRoute("/weather")({ validateSearch: zodValidator(weatherSearchParamsSchema), loaderDeps: ({ search }) => ({ cityId: search.cityId }), loader: async ({ context, deps }) => await context.queryClient.ensureQueryData(WeatherQueryOption(deps.cityId)), component: RouteComponent,});
function RouteComponent() { const data = Route.useDataLoader()
return ( <div className="p-4 max-w-2xl mx-auto flex flex-col gap-4"> <Link to="/" className="self-start">← 一覧へ戻る</Link> <div className="card bg-base-100 shadow-xl p-4 grid gap-8 place-items-center"> <h2 className="text-xl font-bold"> {data.publicTimeFormatted.split(" ")[0]} {data.title} </h2> <div className="grid grid-cols-2 grid-rows-2 place-items-center gap-x-8"> <div>気温</div> <div className="text-xl flex flex-col items-center"> <p>{data.forecasts[0].telop}</p> </div> <div className="text-xl flex items-center"> <p className="text-3xl text-blue-300 mx-2"> {data.forecasts[0].temperature.min?.celsius ?? "-"} </p> / <p className="text-3xl text-red-300 mx-2"> {data.forecasts[0].temperature.max.celsius ?? "-"} </p> °C </div> <div className="text-xl flex flex-col items-center"> <img src={data.forecasts[0].image.url} /> </div> </div> <div className="whitespace-pre-line text-left"> {data.description.text} </div> </div> </div> );}
まとめ
- ページルーティング行うには TanStack Router
- サーバーからデータを取得するには TanStack Query