React総合演習
天気予報アプリを作ろう — これまで学んだ知識の総まとめ
プロジェクト概要
この演習では、これまでに学んだReactの知識を総動員して天気予報アプリを コードウォークスルー形式で構築します。以下の機能を実装します。
- 都市名を入力して天気を検索
- 現在の天気情報を表示
- 5日間の予報をリスト表示
- 検索履歴の保存と表示
- ローディング・エラー状態の管理
使用するReactの概念:JSX、コンポーネント、Props、useState、useEffect、イベント処理、リスト表示、カスタムHook
Step 1: 天気データ取得のカスタムHook
まず、天気APIからデータを取得するロジックをカスタムHookに分離します。 ローディング状態とエラーハンドリングも含めます。
// hooks/useWeather.js
import { useState, useCallback } from "react";
// 天気データの型(TypeScriptの場合)
// interface WeatherData {
// city: string;
// temp: number;
// description: string;
// icon: string;
// humidity: number;
// wind: number;
// forecast: ForecastDay[];
// }
const API_KEY = "YOUR_API_KEY"; // OpenWeatherMap API キー
const BASE_URL = "https://api.openweathermap.org/data/2.5";
export function useWeather() {
const [weather, setWeather] = useState(null);
const [forecast, setForecast] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchWeather = useCallback(async (city) => {
if (!city.trim()) return;
setLoading(true);
setError(null);
try {
// 現在の天気を取得
const weatherRes = await fetch(
`${BASE_URL}/weather?q=${city}&appid=${API_KEY}&units=metric&lang=ja`
);
if (!weatherRes.ok) {
throw new Error(
weatherRes.status === 404
? "都市が見つかりません"
: "天気データの取得に失敗しました"
);
}
const weatherData = await weatherRes.json();
// 5日間予報を取得
const forecastRes = await fetch(
`${BASE_URL}/forecast?q=${city}&appid=${API_KEY}&units=metric&lang=ja`
);
const forecastData = await forecastRes.json();
// データを整形
setWeather({
city: weatherData.name,
temp: Math.round(weatherData.main.temp),
description: weatherData.weather[0].description,
icon: weatherData.weather[0].icon,
humidity: weatherData.main.humidity,
wind: Math.round(weatherData.wind.speed * 10) / 10,
});
// 1日1件に間引く(3時間ごとのデータ → 日次)
const dailyForecast = forecastData.list
.filter((_, index) => index % 8 === 0)
.slice(0, 5)
.map(item => ({
date: new Date(item.dt * 1000).toLocaleDateString("ja-JP", {
month: "short",
day: "numeric",
weekday: "short",
}),
temp: Math.round(item.main.temp),
description: item.weather[0].description,
icon: item.weather[0].icon,
}));
setForecast(dailyForecast);
} catch (err) {
setError(err.message);
setWeather(null);
setForecast([]);
} finally {
setLoading(false);
}
}, []);
return { weather, forecast, loading, error, fetchWeather };
}Step 2: 検索コンポーネント
都市名を入力して検索するフォームコンポーネントです。useStateでフォームの入力値を管理し、onSubmitで送信を処理します。
// components/SearchForm.jsx
import { useState } from "react";
function SearchForm({ onSearch, loading }) {
const [city, setCity] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (city.trim()) {
onSearch(city.trim());
}
};
return (
<form onSubmit={handleSubmit} style={{
display: "flex",
gap: "8px",
marginBottom: "24px",
}}>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="都市名を入力(例: Tokyo)"
disabled={loading}
style={{
flex: 1,
padding: "12px 16px",
borderRadius: "8px",
border: "2px solid #ddd",
fontSize: "16px",
}}
/>
<button
type="submit"
disabled={loading || !city.trim()}
style={{
padding: "12px 24px",
borderRadius: "8px",
border: "none",
background: loading ? "#ccc" : "#10b981",
color: "white",
fontSize: "16px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{loading ? "検索中..." : "検索"}
</button>
</form>
);
}
export default SearchForm;Step 3: 天気表示コンポーネント
現在の天気と5日間予報を表示するコンポーネントです。Propsでデータを受け取り、条件付きレンダリングで表示を切り替えます。
// components/WeatherDisplay.jsx
// 現在の天気
function CurrentWeather({ weather }) {
if (!weather) return null;
return (
<div style={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
borderRadius: "16px",
padding: "32px",
color: "white",
marginBottom: "24px",
}}>
<h2 style={{ fontSize: "24px", marginBottom: "8px" }}>
{weather.city}
</h2>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<img
src={`https://openweathermap.org/img/wn/${weather.icon}@2x.png`}
alt={weather.description}
style={{ width: "80px", height: "80px" }}
/>
<div>
<p style={{ fontSize: "48px", fontWeight: "bold" }}>
{weather.temp}°C
</p>
<p style={{ fontSize: "18px" }}>{weather.description}</p>
</div>
</div>
<div style={{ display: "flex", gap: "24px", marginTop: "16px" }}>
<span>湿度: {weather.humidity}%</span>
<span>風速: {weather.wind} m/s</span>
</div>
</div>
);
}
// 5日間予報(リスト表示 + Keyの活用)
function Forecast({ forecast }) {
if (forecast.length === 0) return null;
return (
<div>
<h3 style={{ fontSize: "18px", marginBottom: "12px" }}>
5日間の予報
</h3>
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
gap: "12px",
}}>
{forecast.map((day, index) => (
<div
key={day.date + index}
style={{
background: "#f3f4f6",
borderRadius: "12px",
padding: "16px",
textAlign: "center",
}}
>
<p style={{ fontWeight: "bold", marginBottom: "4px" }}>
{day.date}
</p>
<img
src={`https://openweathermap.org/img/wn/${day.icon}.png`}
alt={day.description}
style={{ width: "48px", height: "48px" }}
/>
<p style={{ fontSize: "20px", fontWeight: "bold" }}>
{day.temp}°C
</p>
<p style={{ fontSize: "12px", color: "#666" }}>
{day.description}
</p>
</div>
))}
</div>
</div>
);
}
export { CurrentWeather, Forecast };Step 4: 検索履歴コンポーネント
検索した都市の履歴を表示し、クリックで再検索できるコンポーネントです。イベント処理とリスト表示の実践です。
// components/SearchHistory.jsx
function SearchHistory({ history, onSelect, onClear }) {
if (history.length === 0) return null;
return (
<div style={{
marginBottom: "24px",
padding: "16px",
background: "#f9fafb",
borderRadius: "12px",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
}}>
<h3 style={{ fontSize: "14px", color: "#666" }}>検索履歴</h3>
<button
onClick={onClear}
style={{
background: "none",
border: "none",
color: "#ef4444",
cursor: "pointer",
fontSize: "12px",
}}
>
クリア
</button>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
{history.map((city, index) => (
<button
key={city + index}
onClick={() => onSelect(city)}
style={{
padding: "6px 12px",
borderRadius: "20px",
border: "1px solid #d1d5db",
background: "white",
cursor: "pointer",
fontSize: "14px",
}}
>
{city}
</button>
))}
</div>
</div>
);
}
export default SearchHistory;Step 5: メインAppコンポーネント
すべてのコンポーネントを組み合わせて、完成したアプリを構築します。コンポーネントの組み合わせ、状態のリフトアップ、useEffectを活用します。
// App.jsx
import { useState, useEffect } from "react";
import { useWeather } from "./hooks/useWeather";
import SearchForm from "./components/SearchForm";
import { CurrentWeather, Forecast } from "./components/WeatherDisplay";
import SearchHistory from "./components/SearchHistory";
function App() {
const { weather, forecast, loading, error, fetchWeather } = useWeather();
// 検索履歴(localStorageと同期)
const [history, setHistory] = useState(() => {
const saved = localStorage.getItem("weatherHistory");
return saved ? JSON.parse(saved) : [];
});
// 履歴をlocalStorageに保存
useEffect(() => {
localStorage.setItem("weatherHistory", JSON.stringify(history));
}, [history]);
// 検索実行
const handleSearch = (city) => {
fetchWeather(city);
// 履歴に追加(重複を除外、最大10件)
setHistory(prev => {
const filtered = prev.filter(c => c.toLowerCase() !== city.toLowerCase());
return [city, ...filtered].slice(0, 10);
});
};
// 履歴クリア
const handleClearHistory = () => {
setHistory([]);
};
return (
<div style={{
maxWidth: "600px",
margin: "0 auto",
padding: "24px",
fontFamily: "sans-serif",
}}>
<h1 style={{
fontSize: "28px",
fontWeight: "bold",
marginBottom: "24px",
textAlign: "center",
}}>
天気予報アプリ
</h1>
<SearchForm onSearch={handleSearch} loading={loading} />
<SearchHistory
history={history}
onSelect={handleSearch}
onClear={handleClearHistory}
/>
{/* エラー表示 */}
{error && (
<div style={{
padding: "12px 16px",
background: "#fef2f2",
color: "#dc2626",
borderRadius: "8px",
marginBottom: "16px",
}}>
{error}
</div>
)}
{/* ローディング表示 */}
{loading && (
<div style={{ textAlign: "center", padding: "32px", color: "#666" }}>
<p>天気データを取得中...</p>
</div>
)}
{/* 天気表示 */}
{!loading && weather && (
<>
<CurrentWeather weather={weather} />
<Forecast forecast={forecast} />
</>
)}
{/* 初期状態 */}
{!loading && !weather && !error && (
<div style={{
textAlign: "center",
padding: "48px",
color: "#9ca3af",
}}>
<p style={{ fontSize: "48px" }}>🅞</p>
<p>都市名を入力して天気を検索してください</p>
</div>
)}
</div>
);
}
export default App;学習のポイント
この天気予報アプリで使用したReactの概念を振り返りましょう。
JSX
条件付きレンダリング、式の埋め込み、フラグメントの活用
コンポーネント設計
SearchForm、WeatherDisplay、SearchHistoryへの分割
Props
親から子へのデータ渡し、コールバック関数の受け渡し
State管理
useState、関数型初期化、配列のイミュータブル更新
useEffect
localStorageとの同期、依存配列の正しい指定
カスタムHook
useWeatherでAPI通信ロジックを再利用可能に
イベント処理
onSubmit、onChange、onClick、preventDefaultの活用
リスト表示
予報データのmap表示、検索履歴のkey指定
発展課題
さらにスキルアップしたい場合は、以下の機能を追加してみましょう。
- 位置情報APIを使って現在地の天気を自動表示
- テーマ切り替え(ライト / ダーク モード)
- 天気に応じた背景色やアニメーションの変更
- React Routerで都市ごとの詳細ページを追加
- お気に入り都市の登録・管理機能
- グラフライブラリ(Recharts等)で気温推移をチャート表示
React入門コース完了!
おめでとうございます! React入門コースの全10レッスンを修了しました。 JSXの基本からコンポーネント設計、状態管理、副作用処理、ルーティングまで、 Reactの基礎をしっかりと学びました。
次のステップとしては、TypeScriptとの組み合わせ、Next.jsでのフルスタック開発、 状態管理ライブラリ(Zustand、Redux)の学習がおすすめです。 実際のプロジェクトを作りながら、スキルを磨いていきましょう!