← ハンズオン一覧に戻る

Reactハンズオン「天気予報アプリ」

2024-2025 Shunsuke Watanabe

このハンズオンはReact入門チュートリアルの内容を前提にしています。

このハンズオンでは天気予報アプリを作ります。

このアプリの作成を通じて以下の知識の確認をします。

1. プロジェクト初期化

Terminal window
npm create vite@latest weather-app
cd weather-app
npm install

必要なライブラリのインストール

Terminal window
npm install @tanstack/react-router @tanstack/react-query zod @tanstack/zod-adapter
npm install -D tailwindcss @tailwindcss/vite daisyui @tanstack/router-plugin @tanstack/router-devtools @tanstack/eslint-plugin-router

Viteの設定変更

  1. 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()],
    })
  2. index.css で TailwindCSSとDaisyUIを読み込み

    @import "tailwindcss";
    @plugin "daisyui" {
    themes: cupcake --default;
    }
  3. 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. ルートの追加

  1. src/routes/__root.tsx を作成
  2. src/routes/index.tsx を作成
  3. 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. 都市一覧ページ作成

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. 天気データの取得

  1. 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>
);
}
  1. 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;
  1. 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),
})
  1. 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>
);
}

まとめ