Project: Weather App
A weather app is the canonical project for learning real-world data fetching in React. Unlike a to-do list, it talks to an external service you do not control, which forces you to handle the messy realities of network code: latency, errors, empty states, and rate limits. In this project you will build a searchable weather lookup that fetches live data from a public API, manages loading and error states cleanly, and renders results — first with plain fetch and async/await, then with TanStack Query for a production-grade version.
What you will build
The app has a search input where the user types a city name. On submit, it fetches current conditions from the Open-Meteo API (geocoding plus forecast endpoints — no API key required, which keeps the example self-contained), then displays the temperature, wind speed, and conditions. Four UI states matter: idle (nothing searched yet), loading, error, and success.
Scaffold a project with Vite:
npm create vite@latest weather-app -- --template react
cd weather-app && npm install && npm run dev
Fetching the data
Keep the network logic out of your components. A small async function that returns plain data is far easier to test and reuse than fetch calls scattered through JSX.
// src/api/weather.js
const GEO = "https://geocoding-api.open-meteo.com/v1/search";
const FORECAST = "https://api.open-meteo.com/v1/forecast";
export async function getWeather(city) {
const geoRes = await fetch(`${GEO}?name=${encodeURIComponent(city)}&count=1`);
if (!geoRes.ok) throw new Error("Geocoding request failed");
const geo = await geoRes.json();
if (!geo.results?.length) throw new Error(`No location found for "${city}"`);
const { latitude, longitude, name, country } = geo.results[0];
const url = `${FORECAST}?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,wind_speed_10m,weather_code`;
const res = await fetch(url);
if (!res.ok) throw new Error("Weather request failed");
const data = await res.json();
return { name, country, ...data.current };
}
Always check
res.ok. Afetchonly rejects on network failure — a 404 or 500 still resolves successfully, so you must inspect the status yourself or you will silently parse error pages as JSON.
Managing state by hand
The manual version uses useState for the query, the result, and the two async flags. The async submit handler resets error and result before awaiting, then sets exactly one of them in the try/catch.
// src/App.jsx
import { useState } from "react";
import { getWeather } from "./api/weather";
export default function App() {
const [city, setCity] = useState("");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
if (!city.trim()) return;
setLoading(true);
setError(null);
setData(null);
try {
setData(await getWeather(city.trim()));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<main>
<h1>Weather</h1>
<form onSubmit={handleSubmit}>
<input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Enter a city"
aria-label="City name"
/>
<button disabled={loading}>Search</button>
</form>
{loading && <p>Loading…</p>}
{error && <p role="alert">{error}</p>}
{data && (
<section>
<h2>{data.name}, {data.country}</h2>
<p>{data.temperature_2m}°C · wind {data.wind_speed_10m} km/h</p>
</section>
)}
{!loading && !error && !data && <p>Search for a city to begin.</p>}
</main>
);
}
Each render shows precisely one state because the handler clears the others first. The disabled button prevents duplicate submissions while a request is in flight.
Output:
Berlin, DE
14.2°C · wind 11.5 km/h
The TanStack Query version
Hand-managing loading and error flags works, but it does not cache, dedupe, or retry. TanStack Query replaces all four useState calls with one hook and adds those features for free.
npm install @tanstack/react-query
Wrap the tree in a provider, then call useQuery:
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getWeather } from "./api/weather";
export default function App() {
const [city, setCity] = useState("");
const [submitted, setSubmitted] = useState("");
const { data, error, isFetching } = useQuery({
queryKey: ["weather", submitted],
queryFn: () => getWeather(submitted),
enabled: Boolean(submitted), // skip until a city is submitted
retry: 1,
});
return (
<main>
<form onSubmit={(e) => { e.preventDefault(); setSubmitted(city.trim()); }}>
<input value={city} onChange={(e) => setCity(e.target.value)} />
<button>Search</button>
</form>
{isFetching && <p>Loading…</p>}
{error && <p role="alert">{error.message}</p>}
{data && <p>{data.name}: {data.temperature_2m}°C</p>}
</main>
);
}
Searching the same city twice now serves a cached result instantly, and retry: 1 gives transient failures a second chance.
Choosing an approach
| Concern | Manual fetch + useState | TanStack Query |
|---|---|---|
| Setup | None | Provider + install |
| Caching / dedupe | Manual | Built in |
| Retry & refetch | Manual | Config flags |
| Best for | Learning, tiny apps | Real apps |
A note on API keys
Open-Meteo needs no key, but most providers (OpenWeatherMap, WeatherAPI) do. Never put a secret key in client code — it ships to every browser and shows up in the network tab. Put it behind your own backend or serverless function and call that. For build-time public values use Vite’s import.meta.env.VITE_* convention, and remember that anything prefixed VITE_ is still exposed to the client.
If a key would cost you money when leaked, it belongs on a server you control — proxy the request, never embed it.
Best practices
- Isolate fetch logic in a module that returns plain data, so components stay declarative.
- Always check
res.okand throw a descriptive error before parsing JSON. - Model idle, loading, error, and success as distinct, mutually exclusive UI states.
- Disable the submit button while a request is in flight to avoid duplicate calls.
- Use
enabled(or guard the handler) so queries do not fire with an empty input. - Keep secret API keys server-side; only expose values safe for the public client.
- Reach for TanStack Query once you need caching, retries, or background refetching.