天気アプリ(公開API使用)解答例とポイント解説

2025-08-11

はじめに

この記事では、React初学者向けの天気アプリ演習の解答例と、API連携における重要なポイントを詳しく解説します。外部APIとの連携という実践的なスキルを、コードを通じて学んでいきましょう。

解答例コード

完成形のコード全体(src/App.js):

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';

function App() {
  // Stateの宣言
  const [city, setCity] = useState('');
  const [weather, setWeather] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [unit, setUnit] = useState('metric'); // 温度単位(metric:℃, imperial:℉)

  // APIキー(実際には環境変数などで管理するのがベストプラクティス)
  const API_KEY = 'YOUR_API_KEY';

  // 天気データ取得関数
  const fetchWeather = async (cityName) => {
    if (!cityName) return;

    setLoading(true);
    setError('');

    try {
      const response = await axios.get(
        `https://api.openweathermap.org/data/2.5/weather?q=${cityName}&appid=${API_KEY}&units=${unit}&lang=ja`
      );
      setWeather(response.data);
    } catch (err) {
      setError('都市が見つかりませんでした。正しい都市名を入力してください');
      setWeather(null);
      console.error('APIエラー:', err);
    } finally {
      setLoading(false);
    }
  };

  // 初期表示時に東京の天気を取得
  useEffect(() => {
    fetchWeather('Tokyo');
  }, []); // 空の依存配列 = 初回レンダリング時のみ実行

  // 温度単位が変更されたら再取得
  useEffect(() => {
    if (weather) {
      fetchWeather(city || weather.name);
    }
  }, [unit]);

  // 温度単位を切り替える関数
  const toggleUnit = () => {
    setUnit(unit === 'metric' ? 'imperial' : 'metric');
  };

  // 天気アイコンを取得する関数
  const getWeatherIcon = (iconCode) => {
    return `http://openweathermap.org/img/wn/${iconCode}@2x.png`;
  };

  // 温度表示をフォーマットする関数
  const formatTemp = (temp) => {
    return unit === 'metric' ? `${Math.round(temp)}°C` : `${Math.round(temp)}°F`;
  };

  // 風速表示をフォーマットする関数
  const formatWindSpeed = (speed) => {
    return unit === 'metric' ? `${speed} m/s` : `${(speed * 2.237).toFixed(1)} mph`;
  };

  return (
    <div className="app">
      <h1>天気予報アプリ</h1>

      {/* 検索フォーム */}
      <div className="search-container">
        <div className="search-form">
          <input
            type="text"
            value={city}
            onChange={(e) => setCity(e.target.value)}
            placeholder="都市名を入力(例: Osaka)"
            onKeyPress={(e) => e.key === 'Enter' && fetchWeather(city)}
          />
          <button 
            onClick={() => fetchWeather(city)} 
            disabled={loading || !city.trim()}
          >
            {loading ? '取得中...' : '検索'}
          </button>
        </div>

        {/* 温度単位切り替えボタン */}
        <button className="unit-toggle" onClick={toggleUnit}>
          {unit === 'metric' ? '℉ 表示' : '℃ 表示'}
        </button>
      </div>

      {/* エラーメッセージ */}
      {error && <div className="error-message">{error}</div>}

      {/* 天気情報表示 */}
      {weather && (
        <div className="weather-container">
          <div className="weather-header">
            <h2>
              {weather.name}, {weather.sys.country}
              <span className="current-date">
                {new Date().toLocaleDateString('ja-JP', {
                  year: 'numeric',
                  month: 'long',
                  day: 'numeric',
                  weekday: 'short'
                })}
              </span>
            </h2>
          </div>

          <div className="weather-main">
            <div className="weather-primary">
              <img 
                src={getWeatherIcon(weather.weather[0].icon)} 
                alt={weather.weather[0].description} 
              />
              <div className="weather-temp">
                {formatTemp(weather.main.temp)}
                <span className="weather-description">
                  {weather.weather[0].description}
                </span>
              </div>
            </div>

            <div className="weather-details">
              <div className="detail-item">
                <span className="detail-label">体感温度</span>
                <span className="detail-value">{formatTemp(weather.main.feels_like)}</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">湿度</span>
                <span className="detail-value">{weather.main.humidity}%</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">気圧</span>
                <span className="detail-value">{weather.main.pressure} hPa</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">風速</span>
                <span className="detail-value">{formatWindSpeed(weather.wind.speed)}</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">最低気温</span>
                <span className="detail-value">{formatTemp(weather.main.temp_min)}</span>
              </div>
              <div className="detail-item">
                <span className="detail-label">最高気温</span>
                <span className="detail-value">{formatTemp(weather.main.temp_max)}</span>
              </div>
            </div>
          </div>
        </div>
      )}

      {/* クレジット表示 */}
      <div className="credit">
        <p>Powered by <a href="https://openweathermap.org/" target="_blank" rel="noopener noreferrer">OpenWeatherMap</a></p>
      </div>
    </div>
  );
}

export default App;

重要なポイント解説

1. useStateによる複数状態の管理

const [city, setCity] = useState('');
const [weather, setWeather] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [unit, setUnit] = useState('metric');
  • 複数の状態を個別に管理しています
  • city: 検索する都市名
  • weather: APIから取得した天気データ
  • loading: データ取得中の状態
  • error: エラーメッセージ
  • unit: 温度単位(℃/℉)

2. useEffectによる副作用処理

// 初期表示時に東京の天気を取得
useEffect(() => {
  fetchWeather('Tokyo');
}, []);

// 温度単位が変更されたら再取得
useEffect(() => {
  if (weather) {
    fetchWeather(city || weather.name);
  }
}, [unit]);
  • 初回レンダリング時のみ実行(空の依存配列)
  • 温度単位変更時に再取得(unitを依存配列に指定)
  • クリーンアップ関数は不要なので省略

3. API連携の非同期処理

const fetchWeather = async (cityName) => {
  if (!cityName) return;

  setLoading(true);
  setError('');

  try {
    const response = await axios.get(
      `https://api.openweathermap.org/data/2.5/weather?q=${cityName}&appid=${API_KEY}&units=${unit}&lang=ja`
    );
    setWeather(response.data);
  } catch (err) {
    setError('都市が見つかりませんでした。正しい都市名を入力してください');
    setWeather(null);
    console.error('APIエラー:', err);
  } finally {
    setLoading(false);
  }
};
  • async/await構文を使用した非同期処理
  • 処理開始時にloading状態をtrue
  • 成功時はデータをweather状態に保存
  • 失敗時はエラーメッセージを設定
  • finallyで常にloading状態をfalse

4. ユーザーインターフェースの最適化

// 検索フォーム
onKeyPress={(e) => e.key === 'Enter' && fetchWeather(city)}

// ボタンの無効化
disabled={loading || !city.trim()}

// ローディング表示
{loading ? '取得中...' : '検索'}
  • Enterキーでの検索対応
  • 入力がない場合やローディング中のボタン無効化
  • ローディング状態に応じたボタンテキスト変更

5. データ表示のフォーマット

// 温度表示
const formatTemp = (temp) => {
  return unit === 'metric' ? `${Math.round(temp)}°C` : `${Math.round(temp)}°F`;
};

// 日付表示
{new Date().toLocaleDateString('ja-JP', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  weekday: 'short'
})}
  • 温度単位に応じた表示切り替え
  • 日付のローカライズ表示
  • 数値の丸め処理

CSSスタイルの例

src/App.cssの内容例:

/* 基本スタイル */
body {
  margin: 0;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background-color: #f5f7fa;
  color: #333;
}

.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background-color: white;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
}

h1 {
  text-align: center;
  color: #2c3e50;
  margin-bottom: 30px;
}

/* 検索フォーム */
.search-container {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
  gap: 10px;
}

.search-form {
  display: flex;
  flex-grow: 1;
}

.search-form input {
  flex-grow: 1;
  padding: 12px 15px;
  font-size: 16px;
  border: 1px solid #ddd;
  border-radius: 4px 0 0 4px;
  outline: none;
  transition: border-color 0.3s;
}

.search-form input:focus {
  border-color: #3498db;
}

.search-form button {
  padding: 0 20px;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
  font-weight: bold;
  transition: background-color 0.3s;
}

.search-form button:hover {
  background-color: #2980b9;
}

.search-form button:disabled {
  background-color: #95a5a6;
  cursor: not-allowed;
}

.unit-toggle {
  padding: 12px 15px;
  background-color: #2ecc71;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: bold;
  transition: background-color 0.3s;
}

.unit-toggle:hover {
  background-color: #27ae60;
}

/* エラーメッセージ */
.error-message {
  padding: 10px;
  background-color: #ffecec;
  color: #e74c3c;
  border-radius: 4px;
  margin-bottom: 20px;
  text-align: center;
}

/* 天気情報コンテナ */
.weather-container {
  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.weather-header {
  margin-bottom: 20px;
  text-align: center;
}

.weather-header h2 {
  margin: 0;
  color: #2c3e50;
  font-size: 24px;
}

.current-date {
  display: block;
  font-size: 14px;
  color: #7f8c8d;
  margin-top: 5px;
  font-weight: normal;
}

.weather-main {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.weather-primary {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
}

.weather-primary img {
  width: 100px;
  height: 100px;
}

.weather-temp {
  font-size: 48px;
  font-weight: bold;
  color: #2c3e50;
}

.weather-description {
  display: block;
  font-size: 16px;
  font-weight: normal;
  text-transform: capitalize;
  color: #34495e;
}

.weather-details {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 15px;
}

.detail-item {
  background-color: rgba(255, 255, 255, 0.7);
  padding: 10px;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.detail-label {
  font-size: 12px;
  color: #7f8c8d;
  margin-bottom: 5px;
}

.detail-value {
  font-size: 16px;
  font-weight: bold;
  color: #2c3e50;
}

/* クレジット */
.credit {
  margin-top: 30px;
  text-align: center;
  font-size: 12px;
  color: #7f8c8d;
}

.credit a {
  color: #3498db;
  text-decoration: none;
}

.credit a:hover {
  text-decoration: underline;
}

/* レスポンシブ対応 */
@media (max-width: 480px) {
  .search-container {
    flex-direction: column;
  }

  .search-form {
    width: 100%;
  }

  .unit-toggle {
    width: 100%;
  }

  .weather-details {
    grid-template-columns: 1fr;
  }
}

学ぶべき重要な概念

この天気アプリを通じて学ぶべきReactとAPI連携の重要な概念:

  1. 非同期処理とuseEffect:API通信の適切なタイミング管理
  2. 状態管理:複数の関連する状態をどう管理するか
  3. エラーハンドリング:ユーザーに適切なフィードバックを提供
  4. 条件付きレンダリング:データの有無に応じた表示切り替え
  5. データフォーマット:APIから取得したデータの表示最適化
  6. 環境変数の管理:APIキーなどの秘匿情報の取り扱い

よくある質問

Q: APIキーを直接コードに書いても大丈夫ですか?

A: 実際のプロダクション環境では、APIキーを直接コードに書くのはセキュリティ上危険です。.envファイルを使用して環境変数として管理するのがベストプラクティスです。

REACT_APP_WEATHER_API_KEY=your_api_key_here
const API_KEY = process.env.REACT_APP_WEATHER_API_KEY;

Q: なぜaxiosを使うのですか?fetchではダメですか?

A: fetchでも可能ですが、axiosには以下の利点があります:

  • レスポンスデータの自動JSON変換
  • リクエスト/レスポンスのインターセプト
  • より詳細なエラーハンドリング
  • ブラウザ互換性の処理

Q: useEffectの依存配列とは何ですか?

A: useEffectの第二引数に渡す配列で、ここに指定した値が変更された場合のみeffectが再実行されます。空の配列を渡すと初回レンダリング時のみ実行されます。

Q: なぜ天気データ取得をuseEffect内で行うのですか?

A: 副作用(API通信)はレンダリング中に直接実行すべきではなく、useEffect内で行うのがReactの推奨パターンです。これにより、レンダリングのパフォーマンスが向上し、予期しない副作用を防げます。

発展的な機能のアイデア

  1. 位置情報を使用した自動検索
   useEffect(() => {
     navigator.geolocation?.getCurrentPosition((position) => {
       fetchWeatherByCoords(position.coords.latitude, position.coords.longitude);
     });
   }, []);
  1. 5日間予報の表示
   const fetchForecast = async (cityName) => {
     const response = await axios.get(
       `https://api.openweathermap.org/data/2.5/forecast?q=${cityName}&appid=${API_KEY}&units=${unit}&lang=ja`
     );
     setForecast(response.data.list);
   };
  1. 検索履歴の保存
   const [searchHistory, setSearchHistory] = useState([]);

   const addToHistory = (city) => {
     setSearchHistory(prev => [...new Set([city, ...prev])].slice(0, 5));
   };
  1. テーマ切り替え(昼/夜モード)
   const [theme, setTheme] = useState('light');

   // 天気データから昼夜を判断
   useEffect(() => {
     if (weather) {
       const isDayTime = weather.weather[0].icon.includes('d');
       setTheme(isDayTime ? 'light' : 'dark');
     }
   }, [weather]);

まとめ

この天気アプリの演習を通じて、ReactにおけるAPI連携の基本パターンを学びました。特に重要なのは:

  1. 非同期処理の適切な管理(useEffect + async/await)
  2. ユーザー体験を考慮した状態管理(loading, error状態)
  3. 取得したデータの表示最適化
  4. セキュリティを考慮したAPIキーの取り扱い

次のステップとして、より複雑なAPI連携(認証が必要なAPI)、データのキャッシュ(React Queryなどのライブラリ)、または複数APIを組み合わせたアプリケーション開発に挑戦してみると良いでしょう。