React Weather Application

You just built a React Todo Application, so you already know state and events. Now we go one step further and talk to the real world, by fetching live weather from a real API.

🎯 What We’re Building

We’re building a weather app that takes a city name and shows the live weather for that city. You type a city, hit search, and a nice card appears with the temperature, the condition, an emoji, the humidity, and the wind.

The whole point of this project is talking to a real API. An API is just a service on the internet that you ask for data, and it sends data back. We ask β€œwhat’s the weather in Tokyo?” and it answers.

Here is what the finished app will do:

  • A search box where you type any city name
  • A button (and the Enter key) to fetch the weather
  • A weather card showing temperature, condition, emoji, humidity, wind, and the city name
  • A loading spinner while we wait for the network
  • A clear error message when the city is not found
  • A friendly empty state before you’ve searched anything

Here is roughly what the finished screen looks like once you search for a city.

Output

🌀️ Weather Now
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ [ Tokyo ] [Go] β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Tokyo, JP β”‚
β”‚ β”‚
β”‚ β˜€οΈ β”‚
β”‚ 24Β°C β”‚
β”‚ Clear sky β”‚
β”‚ β”‚
β”‚ πŸ’§ Humidity 55% β”‚
β”‚ πŸ’¨ Wind 3.2 m/s β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

That number and condition come from a live network call, so the exact values change every time you run it. The numbers above are a realistic example, not a fixed result.

πŸ—‚οΈ Project Setup

We’ll use Vite to create the project. Vite is a fast tool that sets up a React app for you with one command.

We also need a free API key from OpenWeatherMap. The key is a private password that lets you use their service. You sign up, copy your key, and we’ll keep it in a safe file.

  1. Create the project with Vite. Run this in your terminal.

    Terminal window
    npm create vite@latest weather-app -- --template react
    cd weather-app
    npm install
  2. Get a free API key. Go to openweathermap.org, create a free account, and open the β€œAPI keys” page. Copy your key. A new key can take a little while to activate, so give it some time if it doesn’t work right away.

  3. Create a .env file in the project root (next to package.json) and put your key inside. The name must start with VITE_ or Vite will hide it from your app.

    Terminal window
    VITE_WEATHER_KEY=your_real_key_here
  4. Tell Git to ignore that file. Open .gitignore and make sure this line is there so your key never leaves your computer.

    Terminal window
    .env
  5. Start the dev server to check everything runs.

    Terminal window
    npm run dev

Danger

Never commit a real API key to GitHub. Anyone can read your public repo, copy the key, and run up your usage. Keep the key in .env, keep .env in .gitignore, and if a key ever leaks, delete it on the OpenWeatherMap site and make a new one.

Here is the file structure we’ll end up with. We only touch a couple of files.

weather-app/
β”œβ”€β”€ .env ← your secret API key (ignored by Git)
β”œβ”€β”€ .gitignore
β”œβ”€β”€ index.html
β”œβ”€β”€ package.json
└── src/
β”œβ”€β”€ App.jsx ← the whole app lives here
β”œβ”€β”€ App.css ← styles for the card and form
└── main.jsx ← Vite's entry file (leave as is)

We’re keeping the whole app in App.jsx on purpose. It’s one feature, so one file keeps it easy to read. You can split it into components later.

πŸ”‘ Reading the API key safely

Before we fetch anything, let’s grab the key from the environment variable. Vite exposes any variable starting with VITE_ on a special object called import.meta.env.

This single line reads the key into a normal variable we can use.

const API_KEY = import.meta.env.VITE_WEATHER_KEY;

Here’s what’s happening with that line:

  • import.meta.env is Vite’s box of environment variables.
  • VITE_WEATHER_KEY is the exact name we wrote in the .env file.
  • We never type the real key in our code, so it stays out of Git.

The reason we do it this way is safety. Your code can be shared, but your .env file stays private. Two people can run the same app with two different keys, and nobody’s secret ends up in the repo.

🧱 The state we need

Let’s think about what changes on the screen, because anything that changes needs state. State is React’s memory for a component.

Here is the memory this app keeps track of:

  • city β€” what the user is typing in the box
  • weather β€” the data we got back, or null before any search
  • loading β€” true while we wait for the network
  • error β€” a message string when something goes wrong, else empty

This is the starting point of App.jsx with all the state values.

import { useState } from "react";
import "./App.css";
const API_KEY = import.meta.env.VITE_WEATHER_KEY;
function App() {
const [city, setCity] = useState("");
const [weather, setWeather] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
return (
<div className="app">
<h1>🌀️ Weather Now</h1>
</div>
);
}
export default App;

Here’s the reason for each piece:

  • We separate loading and error from weather so each screen state is clean. The card shows only when we truly have data.
  • weather starts as null so we can tell β€œnothing searched yet” apart from β€œgot a result”.
  • error is a plain string, so showing it is just printing the text.

Keeping these apart means we never show a half-loaded card or a stale error. Each state controls exactly one thing on screen.

πŸ” The search form

Now the input box and the button. The input is a controlled input, which means React state holds its value and updates on every keystroke.

This is the form, placed inside the return under the heading.

<form className="search" onSubmit={handleSearch}>
<input
type="text"
placeholder="Enter a city..."
value={city}
onChange={(e) => setCity(e.target.value)}
/>
<button type="submit">Go</button>
</form>

Reading through the form:

  • value={city} ties the box to our city state, so the state always matches what’s on screen.
  • onChange runs on every keystroke and saves the new text with setCity.
  • We use a form with onSubmit, so pressing Enter works the same as clicking the button.

Using a real form is the why here. The browser gives us Enter-to-submit for free, and onSubmit fires for both the button and the keyboard. We’ll stop the page from reloading inside the handler next.

πŸ“‘ Fetching the weather

This is the heart of the app. When the user searches, we call the API, wait for the answer, and save it. We use fetch with async/await, which is the modern way to wait for a network call without messy callbacks.

Here is the full handler. Add it inside the component, above the return.

async function handleSearch(e) {
e.preventDefault();
const query = city.trim();
if (!query) return;
setLoading(true);
setError("");
setWeather(null);
try {
const url =
`https://api.openweathermap.org/data/2.5/weather` +
`?q=${encodeURIComponent(query)}&units=metric&appid=${API_KEY}`;
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) {
throw new Error("City not found. Check the spelling and try again.");
}
throw new Error("Something went wrong. Please try again.");
}
const data = await res.json();
setWeather(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}

Let’s walk through it step by step:

  • e.preventDefault() stops the form from reloading the whole page, which forms do by default.
  • city.trim() removes extra spaces, and if the box is empty we just stop.
  • Before the call we set loading to true and clear out any old error and old weather, so the screen resets cleanly.
  • encodeURIComponent(query) makes city names with spaces (like β€œNew York”) safe to put in a URL.
  • units=metric asks the API for Celsius and m/s instead of the default Kelvin.
  • await fetch(url) sends the request and waits for the response.
  • res.ok is false for errors like 404, so we throw a friendly message that lands in catch.
  • res.json() turns the response text into a real JavaScript object we can read.
  • finally always runs, so loading is turned off whether we succeeded or failed.

The big idea is that one handler manages the whole journey. We flip loading on, try the call, save data or save an error, and flip loading off at the end. Because finally always runs, the spinner can never get stuck on screen.

Tip

try/catch/finally is your safety net for network code. The network can fail for many reasons, so always assume a call might break, and always turn off loading in finally.

πŸ–ΌοΈ Showing each state

Now we show the right thing for each situation, and only one shows at a time.

This block goes in the return, right under the form.

{loading && <div className="spinner" />}
{error && <p className="error">⚠️ {error}</p>}
{!loading && !error && !weather && (
<p className="hint">Search for a city to see the weather.</p>
)}
{weather && !loading && !error && (
<WeatherCard data={weather} />
)}

Here is how each line decides what to show:

  • loading && ... shows the spinner only while the call is running.
  • error && ... shows the red message only when something failed.
  • The empty hint shows only when we’re idle: not loading, no error, and no weather yet.
  • The card shows only when we actually have weather and nothing is wrong.

This is why we kept the states separate earlier. Each condition checks a clear set of flags, so the screens never overlap. The user always sees exactly one clean state.

πŸƒ The weather card

The card is its own small component so the JSX stays tidy. It reads the data the API sent and lays it out nicely. We also pick an emoji from the weather condition.

Here is the WeatherCard component. Put it in the same file, below App.

function getEmoji(main) {
const map = {
Clear: "β˜€οΈ",
Clouds: "☁️",
Rain: "🌧️",
Drizzle: "🌦️",
Thunderstorm: "β›ˆοΈ",
Snow: "❄️",
Mist: "🌫️",
Fog: "🌫️",
Haze: "🌫️",
};
return map[main] || "🌑️";
}
function WeatherCard({ data }) {
const condition = data.weather[0];
return (
<div className="card">
<h2 className="city">
{data.name}, {data.sys.country}
</h2>
<div className="emoji">{getEmoji(condition.main)}</div>
<p className="temp">{Math.round(data.main.temp)}Β°C</p>
<p className="desc">{condition.description}</p>
<div className="details">
<span>πŸ’§ Humidity {data.main.humidity}%</span>
<span>πŸ’¨ Wind {data.wind.speed} m/s</span>
</div>
</div>
);
}

Reading the card from top to bottom:

  • data.weather[0] is the first condition object the API sends, which holds the main type and a description.
  • getEmoji matches the main condition (like β€œRain”) to an emoji, and falls back to a thermometer if it’s something unusual.
  • Math.round(data.main.temp) cleans up the temperature so we show 24Β°C, not 23.87Β°C.
  • data.main.humidity and data.wind.speed come straight from the response.
  • data.name and data.sys.country give us the city and country code for the title.

The reason the card is a separate component is readability. The data shape from the API is a bit nested, so keeping all that reading in one place means App stays focused on the states and the fetch.

Note

The exact field names (data.main.temp, data.wind.speed, and so on) come from OpenWeatherMap’s response shape. If you use a different weather API, the names will differ, so always check that API’s docs for what the response looks like.

🎨 Styling

Now let’s make it look good. A weather app should feel calm and clean, so we use a soft gradient background, a rounded white card, and gentle shadows. The spinner is a spinning circle made with pure CSS.

This is the full App.css. Replace everything in that file with this.

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: linear-gradient(160deg, #6dd5ed, #2193b0);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 48px 16px;
}
.app {
width: 100%;
max-width: 420px;
text-align: center;
color: #fff;
}
.app h1 {
font-size: 1.8rem;
margin-bottom: 24px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.search {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.search input {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 12px;
font-size: 1rem;
outline: none;
}
.search button {
padding: 12px 20px;
border: none;
border-radius: 12px;
background: #ffd166;
color: #333;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s ease, background 0.2s ease;
}
.search button:hover {
background: #ffc233;
}
.search button:active {
transform: scale(0.96);
}
.card {
background: rgba(255, 255, 255, 0.95);
color: #333;
border-radius: 20px;
padding: 28px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
animation: fadeIn 0.3s ease;
}
.card .city {
font-size: 1.4rem;
margin-bottom: 8px;
}
.card .emoji {
font-size: 4rem;
line-height: 1;
margin: 8px 0;
}
.card .temp {
font-size: 3rem;
font-weight: 700;
}
.card .desc {
text-transform: capitalize;
color: #666;
margin-bottom: 20px;
}
.card .details {
display: flex;
justify-content: space-around;
border-top: 1px solid #eee;
padding-top: 16px;
font-size: 0.95rem;
color: #444;
}
.hint {
color: rgba(255, 255, 255, 0.9);
font-size: 1rem;
}
.error {
background: rgba(255, 255, 255, 0.95);
color: #c0392b;
padding: 16px;
border-radius: 12px;
font-weight: 600;
}
.spinner {
width: 44px;
height: 44px;
margin: 24px auto;
border: 5px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

The key styling choices, in plain terms:

  • The gradient background and white card give that classic weather-app feel, calm and readable.
  • border-radius on the card, input, and button makes everything soft and friendly.
  • The spinner is one div with a circle border where only the top edge is white, spun forever with @keyframes spin.
  • fadeIn makes the card slide up gently when it appears, so results don’t pop in harshly.
  • text-transform: capitalize cleans up the API’s lowercase description, like β€œclear sky” into β€œClear sky”.

🧩 The Complete Code

Here is the whole App.jsx in one place. Copy this, make sure your .env has the key, and you have a working app.

import { useState } from "react";
import "./App.css";
const API_KEY = import.meta.env.VITE_WEATHER_KEY;
function getEmoji(main) {
const map = {
Clear: "β˜€οΈ",
Clouds: "☁️",
Rain: "🌧️",
Drizzle: "🌦️",
Thunderstorm: "β›ˆοΈ",
Snow: "❄️",
Mist: "🌫️",
Fog: "🌫️",
Haze: "🌫️",
};
return map[main] || "🌑️";
}
function WeatherCard({ data }) {
const condition = data.weather[0];
return (
<div className="card">
<h2 className="city">
{data.name}, {data.sys.country}
</h2>
<div className="emoji">{getEmoji(condition.main)}</div>
<p className="temp">{Math.round(data.main.temp)}Β°C</p>
<p className="desc">{condition.description}</p>
<div className="details">
<span>πŸ’§ Humidity {data.main.humidity}%</span>
<span>πŸ’¨ Wind {data.wind.speed} m/s</span>
</div>
</div>
);
}
function App() {
const [city, setCity] = useState("");
const [weather, setWeather] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSearch(e) {
e.preventDefault();
const query = city.trim();
if (!query) return;
setLoading(true);
setError("");
setWeather(null);
try {
const url =
`https://api.openweathermap.org/data/2.5/weather` +
`?q=${encodeURIComponent(query)}&units=metric&appid=${API_KEY}`;
const res = await fetch(url);
if (!res.ok) {
if (res.status === 404) {
throw new Error("City not found. Check the spelling and try again.");
}
throw new Error("Something went wrong. Please try again.");
}
const data = await res.json();
setWeather(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="app">
<h1>🌀️ Weather Now</h1>
<form className="search" onSubmit={handleSearch}>
<input
type="text"
placeholder="Enter a city..."
value={city}
onChange={(e) => setCity(e.target.value)}
/>
<button type="submit">Go</button>
</form>
{loading && <div className="spinner" />}
{error && <p className="error">⚠️ {error}</p>}
{!loading && !error && !weather && (
<p className="hint">Search for a city to see the weather.</p>
)}
{weather && !loading && !error && <WeatherCard data={weather} />}
</div>
);
}
export default App;

▢️ Run It

Make sure your .env file has a real key, then start the dev server.

Terminal window
npm run dev

Open the URL it prints (usually http://localhost:5173), type a city like β€œLondon” or β€œTokyo”, and press Enter. You should see the spinner for a moment, then the card.

Output

🌀️ Weather Now
[ London ] [ Go ]
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ London, GB β”‚
β”‚ ☁️ β”‚
β”‚ 17Β°C β”‚
β”‚ Broken clouds β”‚
β”‚ πŸ’§ Humidity 72% πŸ’¨ Wind 4.1 m/s β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Since this is live data, your numbers will be different from these. That’s expected, because you’re seeing the real weather right now.

⚠️ Common Mistakes

A few things trip people up the first time. Here’s what to watch for.

  • Forgetting the VITE_ prefix. Vite only exposes variables that start with VITE_, so a key named WEATHER_KEY will come back as undefined.

    Terminal window
    # ❌ Vite hides this from your app
    WEATHER_KEY=abc123
    # βœ… Vite exposes this
    VITE_WEATHER_KEY=abc123
  • Not restarting after editing .env. Vite reads .env only when it starts, so stop the server and run npm run dev again after changing the key.

  • Forgetting e.preventDefault(). Without it, the form reloads the whole page on submit and your state is wiped.

    // ❌ page reloads, app resets
    async function handleSearch(e) {
    const res = await fetch(url);
    }
    // βœ… stops the reload first
    async function handleSearch(e) {
    e.preventDefault();
    const res = await fetch(url);
    }
  • Forgetting to check res.ok. fetch does not throw on a 404, so without this check you’d try to read weather from an error response and crash.

  • Leaving the spinner stuck on. If you only set loading to false on success, a failed call leaves it spinning forever. Put it in finally so it always turns off.

βœ… Best Practices

These are the habits this project already follows that are worth keeping.

  • Keep the API key in .env and .env in .gitignore, always.
  • Handle every state: idle, loading, error, and success. Real apps live and die by this.
  • Use try/catch/finally for network calls, and reset old data before a new call.
  • Give the user friendly error text, not a raw status code.
  • Use encodeURIComponent for anything you drop into a URL.

πŸš€ Extend It / Make It Your Own

Once the basics work, try these upgrades to make it yours.

  • A 5-day forecast using OpenWeatherMap’s forecast endpoint, shown as a row of small cards.
  • Fahrenheit toggle with a button that switches units between metric and imperial.
  • Use your location with the browser’s geolocation API to show weather for where you are on load.
  • Recent searches saved in localStorage so your last few cities are one click away.
  • A dynamic background that changes color based on the condition, like grey for rain and gold for clear.

🧩 What You’ve Learned

Look at what you can do now.

  • βœ… Fetch live data from a real API with fetch and async/await
  • βœ… Read a secret key safely from import.meta.env
  • βœ… Handle idle, loading, error, and success states cleanly
  • βœ… Build a controlled search form that submits with Enter
  • βœ… Read nested API data and turn it into a polished card
  • βœ… Style a real-looking app with a gradient, a card, and a CSS spinner

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    Why must the environment variable be named VITE_WEATHER_KEY and not just WEATHER_KEY?

    Why: Vite deliberately only exposes variables prefixed with VITE_ on import.meta.env, so a key without it comes back as undefined.

  2. 2

    Why do we put setLoading(false) inside the finally block?

    Why: finally runs no matter what, so loading is always turned off and the spinner can never get stuck on screen.

  3. 3

    Why do we check res.ok after the fetch?

    Why: fetch only rejects on network failure, not on 404 or 500. We check res.ok to catch those and show a friendly error.

  4. 4

    Why is weather initialized to null instead of an empty object?

    Why: Starting at null lets us show the idle hint before any search, and only render the card once real data arrives.

πŸš€ What’s Next?

You can fetch and display live data now, so let’s apply the same skills to a different kind of API and build a search-as-you-type experience next.

React Movie Search Application

Share & Connect