<CodeLearn/>
React レッスン10

React総合演習

天気予報アプリを作ろう — これまで学んだ知識の総まとめ

プロジェクト概要

この演習では、これまでに学んだReactの知識を総動員して天気予報アプリを コードウォークスルー形式で構築します。以下の機能を実装します。

  • 都市名を入力して天気を検索
  • 現在の天気情報を表示
  • 5日間の予報をリスト表示
  • 検索履歴の保存と表示
  • ローディング・エラー状態の管理

使用するReactの概念:JSXコンポーネントPropsuseStateuseEffectイベント処理リスト表示カスタム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}&deg;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}&deg;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" }}>&#127326;</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でのフルスタック開発、 状態管理ライブラリ(ZustandRedux)の学習がおすすめです。 実際のプロジェクトを作りながら、スキルを磨いていきましょう!