Building a Weather Dashboard with React and a Free API


Weather dashboards are one of my favourite beginner-to-intermediate React projects. They’re practical, visually interesting, and they teach you real skills—API calls, state management, conditional rendering, and error handling. Plus, you end up with something you’ll actually use.

I’m going to walk through building one using React and the OpenWeatherMap API, which has a generous free tier. We’ll handle search, loading states, error states, and display current weather plus a five-day forecast. No unnecessary libraries. No over-engineering.

Setting Up

Start with a fresh React project. I’m using Vite because it’s fast and simple:

npm create vite@latest weather-dashboard -- --template react
cd weather-dashboard
npm install

You’ll need an OpenWeatherMap API key. Sign up at their site—the free tier gives you 60 calls per minute, which is more than enough for a personal project. Create a .env file in your project root:

VITE_WEATHER_API_KEY=your_api_key_here

That VITE_ prefix is important—Vite only exposes environment variables to client code if they start with VITE_.

The API Layer

First, let’s build a clean API layer. Create a src/api/weather.js file:

const API_KEY = import.meta.env.VITE_WEATHER_API_KEY;
const BASE_URL = 'https://api.openweathermap.org/data/2.5';

export async function getCurrentWeather(city) {
  const response = await fetch(
    `${BASE_URL}/weather?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric`
  );

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error('City not found');
    }
    throw new Error('Failed to fetch weather data');
  }

  return response.json();
}

export async function getForecast(city) {
  const response = await fetch(
    `${BASE_URL}/forecast?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric`
  );

  if (!response.ok) {
    throw new Error('Failed to fetch forecast data');
  }

  return response.json();
}

Notice the encodeURIComponent on the city name. This handles spaces and special characters in city names. The units=metric parameter gives us Celsius instead of Kelvin—much more useful.

Separating the API layer is a habit worth building early. It keeps your components focused on rendering and lets you change API providers without touching component code. This pattern comes up constantly in professional React app development—separating concerns makes everything easier to test and maintain.

The Search Component

The search component is straightforward—an input and a button:

import { useState } from 'react';

function SearchBar({ onSearch }) {
  const [query, setQuery] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    if (query.trim()) {
      onSearch(query.trim());
      setQuery('');
    }
  }

  return (
    <form onSubmit={handleSubmit} className="search-bar">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Enter city name..."
        aria-label="City name"
      />
      <button type="submit">Search</button>
    </form>
  );
}

export default SearchBar;

Two things to note. First, we’re using a form with onSubmit rather than a button onClick. This means Enter key works automatically. Second, the aria-label on the input improves accessibility—screen readers will know what the input is for.

The Weather Display Component

This is where it gets interesting. We need to handle three states: loading, error, and success.

function WeatherDisplay({ data, loading, error }) {
  if (loading) {
    return <div className="weather-display loading">Loading...</div>;
  }

  if (error) {
    return <div className="weather-display error">{error}</div>;
  }

  if (!data) {
    return (
      <div className="weather-display empty">
        Search for a city to see the weather
      </div>
    );
  }

  const iconUrl = `https://openweathermap.org/img/wn/${data.weather[0].icon}@2x.png`;

  return (
    <div className="weather-display">
      <h2>{data.name}, {data.sys.country}</h2>
      <div className="weather-main">
        <img src={iconUrl} alt={data.weather[0].description} />
        <span className="temperature">
          {Math.round(data.main.temp)}°C
        </span>
      </div>
      <p className="description">{data.weather[0].description}</p>
      <div className="weather-details">
        <div>Feels like: {Math.round(data.main.feels_like)}°C</div>
        <div>Humidity: {data.main.humidity}%</div>
        <div>Wind: {Math.round(data.wind.speed * 3.6)} km/h</div>
      </div>
    </div>
  );
}

The wind speed conversion (* 3.6) converts from metres per second to kilometres per hour. The icon URL is from OpenWeatherMap’s built-in icon service—free and requires no additional API calls.

The Forecast Component

The five-day forecast API returns data in three-hour intervals. We need to filter it to get one entry per day:

function ForecastDisplay({ data }) {
  if (!data) return null;

  // Get one forecast per day (noon readings)
  const dailyForecasts = data.list.filter(item =>
    item.dt_txt.includes('12:00:00')
  );

  return (
    <div className="forecast">
      <h3>5-Day Forecast</h3>
      <div className="forecast-grid">
        {dailyForecasts.map(day => (
          <div key={day.dt} className="forecast-day">
            <div className="forecast-date">
              {new Date(day.dt * 1000).toLocaleDateString('en-AU', {
                weekday: 'short',
                month: 'short',
                day: 'numeric'
              })}
            </div>
            <img
              src={`https://openweathermap.org/img/wn/${day.weather[0].icon}.png`}
              alt={day.weather[0].description}
            />
            <div className="forecast-temp">
              {Math.round(day.main.temp_max)}° / {Math.round(day.main.temp_min)}°
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Filtering for 12:00:00 gives us the midday reading for each day, which is a reasonable representation of daily conditions. Not perfect—you miss overnight lows—but good enough for a dashboard.

Wiring It Together

The App component manages state and coordinates everything:

import { useState } from 'react';
import { getCurrentWeather, getForecast } from './api/weather';
import SearchBar from './components/SearchBar';
import WeatherDisplay from './components/WeatherDisplay';
import ForecastDisplay from './components/ForecastDisplay';

function App() {
  const [weather, setWeather] = useState(null);
  const [forecast, setForecast] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function handleSearch(city) {
    setLoading(true);
    setError(null);

    try {
      const [weatherData, forecastData] = await Promise.all([
        getCurrentWeather(city),
        getForecast(city)
      ]);
      setWeather(weatherData);
      setForecast(forecastData);
    } catch (err) {
      setError(err.message);
      setWeather(null);
      setForecast(null);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="app">
      <h1>Weather Dashboard</h1>
      <SearchBar onSearch={handleSearch} />
      <WeatherDisplay data={weather} loading={loading} error={error} />
      <ForecastDisplay data={forecast} />
    </div>
  );
}

The Promise.all call fires both API requests simultaneously rather than sequentially. This cuts the loading time roughly in half. The finally block ensures setLoading(false) runs whether the requests succeed or fail.

Error Handling That Matters

Error handling in tutorials is usually an afterthought—catch the error, show a message, done. In real apps, you want more nuance. Our API layer already distinguishes between “city not found” (404) and other errors. You could extend this:

if (!response.ok) {
  if (response.status === 404) throw new Error('City not found');
  if (response.status === 429) throw new Error('Too many requests. Try again shortly.');
  if (response.status === 401) throw new Error('API key issue. Check configuration.');
  throw new Error('Something went wrong. Try again.');
}

Each error message tells the user something actionable. “City not found” means try a different spelling. “Too many requests” means wait. This is better than a generic “Error occurred” that leaves users guessing.

Where to Go Next

Once you’ve got the basic dashboard working, here are extensions worth trying. Add geolocation to detect the user’s city automatically. Add localStorage to remember the last searched city. Add unit toggling between Celsius and Fahrenheit. Add a loading skeleton instead of a plain “Loading…” message.

Each extension teaches you something new without requiring a complete rebuild. That’s the mark of a well-structured project—it grows without collapsing under its own weight.

The full source code for this project fits in about 200 lines of JavaScript and 100 lines of CSS. Small enough to understand completely, big enough to teach real patterns. That’s the sweet spot for learning projects.