Checking the weather is a small habit that hides a big dependency. I plan meetings, commutes, and weekend trips based on it, and I assume a weather app will be fast, clear, and right most of the time. That expectation is why building a weather app is such a useful React exercise: it forces you to combine user input, async data fetching, error handling, and UI clarity in one tight loop. You are not just rendering data; you are managing uncertainty.
In this post I build a complete weather application with React. You will see how I structure components, how I fetch data from a real API, and how I handle the moments where the API says a city does not exist. I also show how I keep the UI responsive, handle loading states, and avoid common mistakes that make apps feel flaky. By the end, you will have a runnable project, a clean mental model for data flow, and a checklist for production readiness.
Why a weather app still matters in 2026
I still recommend a weather app as a learning project because it mirrors the realities of modern front‑end work. You deal with network latency, API failures, and user input that can be wrong or incomplete. You also get a chance to make decisions about caching and UI feedback, which is where most real apps are won or lost.
A few 2026 realities shape the way I build this:
- Edge caching is common, but API rate limits still hurt if you ignore them.
- Users expect fast feedback, even if the data arrives later.
- AI‑assisted workflows are normal. I often ask a code assistant to draft the first version of a component, then I edit and tighten it. It speeds up scaffolding while I keep control of the logic.
When I compare older approaches to the patterns I use now, the difference is not just tooling. It is the discipline of building small, testable units with clear state boundaries.
Modern approach (2026)
—
Thin components + a focused data hook
Debounced or explicit search
Design tokens and responsive layout
Explicit error states in UI
Basic UI tests for search and error casesThis is the framework I follow in the rest of the build.
System design and data flow
I keep the data path simple: search input → fetch weather → update UI. The trick is in the edges: what happens before the request finishes, and what happens when it fails.
Here is the state model I use:
query: what the user typedstatus:idle,loading,success, orerrorweather: parsed data from the APIerror: a friendly message when the request fails
The high‑level flow looks like this:
1) User enters a city and submits.
2) App sets status to loading and clears prior errors.
3) App calls the weather API with the query.
4) If the API returns a result, parse and store it, set status to success.
5) If the API returns an error (such as city not found), set status to error and show a clear message.
I prefer a dedicated data hook for this. It keeps the App component slim and makes it easier to test. I also keep a tiny formatting utility for date and units, which prevents logic from leaking into JSX.
Project setup with Vite and React 18
I use Vite because it is fast, simple, and friendly for new projects. You can run this on any current Node LTS.
Command sequence:
npm create vite@latest weather-react --template react
cd weather-react
npm install axios react-loader-spinner @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons
Create a .env file at the project root so your API key is not hard‑coded:
VITEOPENWEATHERKEY=yourapikey_here
I keep a minimal structure that still separates responsibilities:
weather-react/
src/
components/
ErrorBanner.jsx
Loader.jsx
SearchBar.jsx
WeatherCard.jsx
hooks/
useWeather.js
utils/
format.js
App.jsx
App.css
main.jsx
I do not include a package.json snippet here because it drifts over time. Vite will generate it, and the install command above ensures the core dependencies exist.
Building the interface
I design the UI as four small components: search input, weather display, loader, and error banner. This makes the main App component feel like a storyboard instead of a tangled script.
src/components/SearchBar.jsx
import { useState } from ‘react‘
export default function SearchBar({ initialValue = ‘‘, onSubmit }) {
const [value, setValue] = useState(initialValue)
const handleSubmit = (e) => {
e.preventDefault()
const trimmed = value.trim()
if (!trimmed) return
onSubmit(trimmed)
}
return (
<input
className=‘city-search‘
type=‘text‘
value={value}
placeholder=‘Search city‘
onChange={(e) => setValue(e.target.value)}
aria-label=‘City search input‘
autoComplete=‘off‘
spellCheck=‘false‘
/>
)
}
I keep the input controlled so I can trim whitespace and avoid empty requests. A button improves accessibility and makes the form work on mobile keyboards. I also disable spellcheck to avoid annoying suggestions for city names.
src/components/WeatherCard.jsx
import { formatDate, formatTemp, formatWind } from ‘../utils/format‘
export default function WeatherCard({ data }) {
if (!data) return null
const { city, country, description, icon, temp, windSpeed, feelsLike, humidity } = data
return (
{city}, {country}
{formatDate(new Date())}
{formatTemp(temp)}
°C
Feels like: {formatTemp(feelsLike)}°C
Humidity: {humidity}%
Wind: {formatWind(windSpeed)}
{description}
)
}
The aria-live region helps screen readers announce updates when the weather changes. I also keep the display text human‑readable.
src/components/ErrorBanner.jsx
export default function ErrorBanner({ message }) {
if (!message) return null
return (
{message}
)
}
This is intentionally tiny. The error is part of the UI state and deserves its own component.
src/components/Loader.jsx
import { Oval } from ‘react-loader-spinner‘
export default function Loader({ isActive }) {
if (!isActive) return null
return (
)
}
I like a loader with clear color contrast so the user sees it on light backgrounds.
Data fetching with a focused hook
I prefer a custom hook so the fetch logic is isolated and testable. It also lets me reuse it later if I want a different UI, like a dashboard card.
src/hooks/useWeather.js
import { useCallback, useState } from ‘react‘
import axios from ‘axios‘
const API_BASE = ‘https://api.openweathermap.org/data/2.5/weather‘
export default function useWeather() {
const [status, setStatus] = useState(‘idle‘)
const [weather, setWeather] = useState(null)
const [error, setError] = useState(‘‘)
const search = useCallback(async (city) => {
setStatus(‘loading‘)
setError(‘‘)
try {
const key = import.meta.env.VITEOPENWEATHERKEY
const response = await axios.get(API_BASE, {
params: {
q: city,
appid: key,
units: ‘metric‘
}
})
const data = response.data
const parsed = {
city: data.name,
country: data.sys.country,
description: data.weather[0].description,
icon: https://openweathermap.org/img/wn/${data.weather[0].icon}@2x.png,
temp: data.main.temp,
feelsLike: data.main.feels_like,
humidity: data.main.humidity,
windSpeed: data.wind.speed
}
setWeather(parsed)
setStatus(‘success‘)
} catch (err) {
const message = err?.response?.status === 404
? ‘City not found. Check spelling and try again.‘
: ‘Weather service is unavailable. Try again soon.‘
setWeather(null)
setError(message)
setStatus(‘error‘)
}
}, [])
return { status, weather, error, search }
}
This hook is explicit: it owns the state, and it returns a clear API. I do not debounce inside the hook because I only fetch on submit. If you later want live search, add a small debounce before calling search.
src/utils/format.js
export function formatDate(date) {
return date.toLocaleDateString(‘en-US‘, {
weekday: ‘long‘,
month: ‘long‘,
day: ‘numeric‘
})
}
export function formatTemp(value) {
return Math.round(value)
}
export function formatWind(value) {
return ${Math.round(value)} m/s
}
I keep formatting helpers small and predictable. This also reduces noise inside JSX.
Wiring it together in App
The App component is now short and easy to scan. It composes the UI, and the hook handles the data.
src/App.jsx
import ‘./App.css‘
import useWeather from ‘./hooks/useWeather‘
import SearchBar from ‘./components/SearchBar‘
import WeatherCard from ‘./components/WeatherCard‘
import ErrorBanner from ‘./components/ErrorBanner‘
import Loader from ‘./components/Loader‘
export default function App() {
const { status, weather, error, search } = useWeather()
return (
Weather Now
)
}
src/main.jsx
import React from ‘react‘
import ReactDOM from ‘react-dom/client‘
import App from ‘./App‘
ReactDOM.createRoot(document.getElementById(‘root‘)).render(
)
With this setup, the app already works. The remaining part is the CSS and a few UX improvements.
Styling and responsiveness
I keep CSS readable, not clever. Most weather apps are used quickly, so clarity beats novelty. The goal is to keep contrast high, spacing calm, and the layout stable as data changes.
src/App.css
* {
font-family: -apple-system, BlinkMacSystemFont, ‘Segoe UI‘, Roboto, Oxygen,
Ubuntu, Cantarell, ‘Open Sans‘, ‘Helvetica Neue‘, sans-serif;
box-sizing: border-box;
}
html {
background-color: #f7f7f7;
}
.App {
display: flex;
flex-direction: column;
align-items: center;
width: min(640px, 92vw);
min-height: 440px;
background-color: #ffffff;
text-align: center;
margin: 64px auto;
border-radius: 12px;
padding: 24px 24px 32px 24px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
gap: 16px;
}
.app-name {
font-size: 2.2rem;
color: #3c7d0b;
margin: 8px 0 4px 0;
}
.search-form {
display: flex;
gap: 8px;
width: 100%;
justify-content: center;
}
.city-search {
flex: 1;
max-width: 360px;
border: 2px solid #cccccc;
outline: none;
border-radius: 20px;
font-size: 16px;
background-color: #e5eef0;
padding: 12px 16px;
color: #333333;
}
.search-button {
border: none;
background: #3c7d0b;
color: #ffffff;
padding: 12px 18px;
border-radius: 20px;
cursor: pointer;
font-weight: 600;
}
.search-button:hover {
background: #2f6208;
}
.city-name {
font-size: 1.4rem;
color: #444444;
}
.date {
font-size: 1rem;
font-weight: 500;
color: #777777;
}
.icon-temp {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 2.6rem;
font-weight: 700;
color: #1e2432;
}
.weather-icon {
width: 72px;
height: 72px;
}
.deg {
font-size: 1.2rem;
vertical-align: super;
}
.des-wind {
font-weight: 500;
color: #666666;
display: grid;
gap: 6px;
}
.desc {
text-transform: capitalize;
}
.error-message {
text-align: center;
color: #d32f2f;
font-size: 1.1rem;
}
.Loader {
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
}
@media (max-width: 640px) {
.search-form {
flex-direction: column;
align-items: stretch;
}
.search-button {
width: 100%;
}
}
The layout stays centered and readable on phones. The search bar becomes a stacked layout so the button is easy to tap.
Error handling, edge cases, and real‑world behavior
In real use, the weather API fails in a few predictable ways. I plan for each one so the UI remains trustworthy.
Common mistakes and how I avoid them:
- Empty queries: I trim and block empty strings in the search form.
- Misspelled cities: I catch 404 responses and show a clear message.
- Rate limits: I do not fire requests on every keystroke. If you later add live search, add a debounce around
search. - Stale results: I clear prior weather data on error so you do not see old data with a new error message.
- Slow network: I show a loader and keep the layout height stable so the UI does not jump.
Edge cases I test manually:
- City names with spaces: New York, Rio de Janeiro
- Unicode city names: São Paulo, München
- Rapid searches: type a city, submit, then another quickly
- Offline mode: disable network and submit, confirm the error message
If you want to improve perceived speed, add a small cache. A simple in‑memory map works well and avoids extra API calls. I usually keep entries for 10 to 15 minutes.
Performance and UX considerations
Weather data is small, so the main cost is network and rendering. In my experience:
- First request on a warm connection typically lands in 150–350ms.
- Cached responses can render in 10–30ms after the hook updates state.
I focus on these UX details instead of chasing micro‑seconds:
- A clear loading state so the app never feels stuck.
- A short error message that tells the user what to do next.
- A consistent layout so the user’s eyes do not hunt around.
A common performance pitfall is re‑rendering too often when you wire search to keypress. If you decide to add live suggestions, use a debounce and only call the API once the user pauses for 300–500ms. That keeps requests low and the UI responsive.
Enhancing search without making it noisy
A weather app feels great when it is intentional. I add helpful behavior, but I avoid surprise network calls or a busy UI. Here are three enhancements I use often.
1) Debounced live search (optional)
I still prefer explicit submit for weather, but if you want suggestions or a live experience, debounce the input and only fetch when the user pauses.
import { useEffect, useState } from ‘react‘
export function useDebouncedValue(value, delay = 400) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(handle)
}, [value, delay])
return debounced
}
I keep it separate from the weather hook. The hook should only do weather. The debounce hook should only debounce.
2) Recent searches
Users often check the same cities. I add a tiny local list so they can tap and re‑fetch quickly.
import { useEffect, useState } from ‘react‘
export function useRecentSearches(key = ‘recentCities‘, limit = 5) {
const [recent, setRecent] = useState(() => {
try {
return JSON.parse(localStorage.getItem(key)) || []
} catch {
return []
}
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(recent))
}, [key, recent])
const add = (city) => {
setRecent((prev) => [city, ...prev.filter((c) => c !== city)].slice(0, limit))
}
return { recent, add }
}
I call add(city) only after a successful fetch so the list stays clean.
3) Press Enter to search
Forms already handle Enter by default, but I still keep a clear button and label so it works on both desktop and mobile keyboards.
Caching to respect rate limits and speed up repeat queries
The easiest cache is a plain object keyed by city name. I store a timestamp so I can expire entries. This is ideal for a simple weather app.
const cache = new Map()
const TTL = 10 60 1000
function readCache(city) {
const cached = cache.get(city.toLowerCase())
if (!cached) return null
if (Date.now() - cached.timestamp > TTL) return null
return cached.data
}
function writeCache(city, data) {
cache.set(city.toLowerCase(), { data, timestamp: Date.now() })
}
Then inside search:
const cached = readCache(city)
if (cached) {
setWeather(cached)
setStatus(‘success‘)
return
}
This reduces duplicate requests when a user comes back to the same city, and it makes the UI feel instant for repeat searches.
Units, localization, and time zones
Weather is global, so I treat units and locale as first‑class concerns. There are three practical improvements I recommend.
1) Unit toggle: Celsius vs Fahrenheit. I store the unit in state and send it to the API.
2) Locale‑aware formatting: I pass the user’s locale to toLocaleDateString when possible.
3) Time zone awareness: Weather data often includes a timezone offset. You can show local time for that city instead of the user’s local time.
Here is a small update to the hook to support units:
const [units, setUnits] = useState(‘metric‘)
const response = await axios.get(API_BASE, {
params: {
q: city,
appid: key,
units
}
})
And in the UI:
I keep the toggle in the App component or a small UnitToggle component. The key is to avoid mixing units in the display. If you switch to Fahrenheit, update wind speed as well (m/s vs mph) so the data stays consistent.
Geolocation as a smart default
A common UX upgrade is a ‘Use my location’ button. It reduces friction for first‑time use and makes the app feel personalized.
I keep it optional and ask for permission only when the user clicks. That builds trust.
function useGeolocation() {
const [coords, setCoords] = useState(null)
const [error, setError] = useState(‘‘)
const request = () => {
if (!navigator.geolocation) {
setError(‘Geolocation is not supported in this browser.‘)
return
}
navigator.geolocation.getCurrentPosition(
(pos) => setCoords({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
() => setError(‘Location permission was denied.‘)
)
}
return { coords, error, request }
}
If I add this, I change the API call to use lat/lon when available. It feels like magic when a user gets weather without typing a city name.
Accessibility and inclusive UX
Weather should be accessible to everyone, including users with screen readers, low vision, or motor constraints. I apply a few simple rules that have a big impact.
- Use
aria-livefor weather updates so screen readers announce changes. - Keep the focus order logical: title, search, button, content.
- Ensure color contrast meets WCAG AA (dark text on light background is an easy win).
- Avoid relying only on color to convey errors. I show error text and use
role=‘alert‘. - Keep click targets large enough for touch: the search button and any toggles should be at least 44px tall.
These are not complex features. They are basics that make your app usable for more people.
Reliability and defensive coding in the hook
APIs fail. Browsers go offline. Users double‑click. I harden the hook with a few small moves.
1) Ignore out‑of‑order responses: If a user searches twice quickly, the second response should win.
2) Stop after unmount: Avoid setting state on unmounted components.
3) Handle missing data gracefully: Some responses can be missing fields.
Here is a lightweight pattern I use to guard stale responses:
const requestId = useRef(0)
const search = useCallback(async (city) => {
const id = ++requestId.current
setStatus(‘loading‘)
setError(‘‘)
try {
const response = await axios.get(API_BASE, { params: { q: city, appid: key, units: ‘metric‘ } })
if (id !== requestId.current) return
// parse and set state
} catch (err) {
if (id !== requestId.current) return
// set error
}
}, [])
This prevents flicker and weird state when results arrive out of order.
Alternative approaches and when I use them
The core design here is intentionally simple. But there are legitimate alternatives depending on the app’s goals.
Fetch instead of Axios
If you want zero dependencies, fetch works well.
const res = await fetch(${API_BASE}?q=${encodeURIComponent(city)}&appid=${key}&units=metric)
if (!res.ok) throw new Error(‘Request failed‘)
const data = await res.json()
I use Axios when I need consistent error handling and easy query params. I use fetch for tiny projects or environments where I want fewer dependencies.
React Query or SWR
If the app grows, I add a data library. It handles caching, retries, and loading state for me.
- React Query: great for complex caching and background refetching.
- SWR: excellent for simple data fetching with a tiny API.
I stick with my custom hook for small projects because it keeps the learning curve low and the code easy to read.
Forecast views
If you want a 5‑day forecast, add a second call to the forecast endpoint and render a horizontal list. I keep the current weather and forecast data separate so the UI remains clean.
Common pitfalls I see in weather apps
I have built this app several times with students and teammates, and the same mistakes repeat. Here is my short list, with fixes.
1) Searching on every keypress
– Problem: too many requests, rate limits, jumpy UI.
– Fix: submit button or debounced search.
2) Skipping error states
– Problem: users see a blank screen and assume the app is broken.
– Fix: explicit error state and clear message.
3) Hard‑coding the API key
– Problem: easy to leak credentials in the repo.
– Fix: environment variables and .env files, plus a note to keep them out of git.
4) Not clearing prior data on error
– Problem: old weather stays visible with a new error, which is confusing.
– Fix: set weather to null on error.
5) Inconsistent units
– Problem: temperature in °C but wind in mph.
– Fix: switch both when you toggle units.
Testing and deployment
You do not need a giant test suite, but a few checks protect against regressions. I use these as a baseline:
- Search a valid city and confirm temperature renders.
- Search an invalid city and confirm the error message.
- Submit an empty search and confirm no request is made.
If you want to automate the basics, I suggest a small setup with Vitest and React Testing Library. It keeps the tests close to how users actually interact with the UI.
Here is a minimal test concept I use:
import { render, screen, fireEvent } from ‘@testing-library/react‘
import App from ‘./App‘
it(‘shows error for unknown city‘, async () => {
render()
fireEvent.change(screen.getByLabelText(‘City search input‘), { target: { value: ‘NoSuchCity‘ } })
fireEvent.click(screen.getByText(‘Search‘))
expect(await screen.findByRole(‘alert‘)).toBeInTheDocument()
})
For deployment, I keep it simple:
- Static hosting: any platform that serves static assets works well.
- Environment variables: make sure the build system injects
VITEOPENWEATHERKEY. - HTTPS only: required for geolocation.
If I want a production‑level setup, I add a proxy server to keep the API key private and to add server‑side caching. That is an optional step but a great real‑world upgrade.
Production readiness checklist
I use this as a final pass before I call the app done.
- [ ] Error states are clear and actionable.
- [ ] Loading state is visible and non‑jarring.
- [ ] Search prevents empty submissions.
- [ ] API key is not committed to git.
- [ ] UI is responsive on small screens.
- [ ] Contrast is readable in bright light.
- [ ] Rate limits are respected (debounce or explicit submit).
- [ ] Caching exists or can be added easily.
When I would not use this approach
This architecture is great for a simple weather app, but I would not use it if:
- I needed real‑time updates across many cities (I would use a data library and a stronger caching layer).
- I needed offline support (I would add a service worker and store recent responses).
- I needed multi‑region compliance (I would add a backend that handles API keys, logging, and audit controls).
The key is not to over‑engineer a learning project, but also not to ignore production realities.
Final thoughts
A weather app is small, but it is a complete microcosm of front‑end engineering. You gather user input, call a real API, handle uncertainty, and present the result in a calm and readable UI. That is most of what production apps do every day.
I like this project because it is honest. It forces you to build the glue between state, network, and interface. If you do it well, the UI feels simple, but the code is deliberate. That is exactly the kind of habit that makes you better at React.
If you build this version, you will have a stable foundation you can extend with forecasts, geolocation, unit toggles, and caching. More importantly, you will have a clean mental model for how to ship a React app that users can actually trust.


