React Weather Application
Table of Contents + β
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.
-
Create the project with Vite. Run this in your terminal.
Terminal window npm create vite@latest weather-app -- --template reactcd weather-appnpm install -
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.
-
Create a
.envfile in the project root (next topackage.json) and put your key inside. The name must start withVITE_or Vite will hide it from your app.Terminal window VITE_WEATHER_KEY=your_real_key_here -
Tell Git to ignore that file. Open
.gitignoreand make sure this line is there so your key never leaves your computer.Terminal window .env -
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.envis Viteβs box of environment variables.VITE_WEATHER_KEYis the exact name we wrote in the.envfile.- 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 boxweatherβ the data we got back, ornullbefore any searchloadingβtruewhile we wait for the networkerrorβ 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
loadinganderrorfromweatherso each screen state is clean. The card shows only when we truly have data. weatherstarts asnullso we can tell βnothing searched yetβ apart from βgot a resultβ.erroris 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 ourcitystate, so the state always matches whatβs on screen.onChangeruns on every keystroke and saves the new text withsetCity.- We use a
formwithonSubmit, 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
loadingtotrueand 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=metricasks the API for Celsius and m/s instead of the default Kelvin.await fetch(url)sends the request and waits for the response.res.okisfalsefor errors like 404, so we throw a friendly message that lands incatch.res.json()turns the response text into a real JavaScript object we can read.finallyalways runs, soloadingis 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
weatherand 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.getEmojimatches 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 show24Β°C, not23.87Β°C.data.main.humidityanddata.wind.speedcome straight from the response.data.nameanddata.sys.countrygive 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-radiuson the card, input, and button makes everything soft and friendly.- The spinner is one
divwith a circle border where only the top edge is white, spun forever with@keyframes spin. fadeInmakes the card slide up gently when it appears, so results donβt pop in harshly.text-transform: capitalizecleans 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.
npm run devOpen 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 withVITE_, so a key namedWEATHER_KEYwill come back asundefined.Terminal window # β Vite hides this from your appWEATHER_KEY=abc123# β Vite exposes thisVITE_WEATHER_KEY=abc123 -
Not restarting after editing
.env. Vite reads.envonly when it starts, so stop the server and runnpm run devagain 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 resetsasync function handleSearch(e) {const res = await fetch(url);}// β stops the reload firstasync function handleSearch(e) {e.preventDefault();const res = await fetch(url);} -
Forgetting to check
res.ok.fetchdoes 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
loadingtofalseon success, a failed call leaves it spinning forever. Put it infinallyso it always turns off.
β Best Practices
These are the habits this project already follows that are worth keeping.
- Keep the API key in
.envand.envin.gitignore, always. - Handle every state: idle, loading, error, and success. Real apps live and die by this.
- Use
try/catch/finallyfor network calls, and reset old data before a new call. - Give the user friendly error text, not a raw status code.
- Use
encodeURIComponentfor 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
unitsbetweenmetricandimperial. - Use your location with the browserβs geolocation API to show weather for where you are on load.
- Recent searches saved in
localStorageso 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
fetchandasync/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
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
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
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
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.