Weather Application Using ReactJS: A Practical, Modern Guide

Skill note: I’m using a documentation-focused approach for clarity and example-driven structure.

You pull your phone out to check the weather, and it gives you a vague “cloudy” label that doesn’t match the sky outside. That small mismatch is exactly why I still build weather apps when teaching or refining front-end skills. A weather app seems simple, yet it forces you to handle real-time data, unreliable inputs, asynchronous work, and UI states that change based on success or failure. If you can build this well, you can build most data-driven interfaces well.

I’ll walk you through a clean, modern React weather application that fetches live conditions by city name, shows wind speed, and handles errors when a city is invalid. I’ll keep it technical but accessible, and I’ll show the implementation I use in 2026: React function components, Vite tooling, environment variables for API keys, and focused UI state. You’ll also get strategies for handling rate limits and edge cases that show up in real-world usage. By the end, you’ll have a runnable app and a mental model you can apply to any API-driven UI.

Why a weather app is a serious UI exercise

I treat a weather app as a compact “systems test” for front-end skill. You’re not just rendering static data. You’re coordinating user intent, remote data, and conditional UI with strict timing expectations. When the user types a city, they expect a response fast enough to feel immediate, but you also need to avoid sending a request on every keypress. When the API fails, you must communicate the failure in a way that doesn’t feel like a crash. And when the data is valid, you must present it clearly for a quick decision: Do I need a jacket right now?

This is where I see small but meaningful design choices matter. A clear loading state builds trust. A wrong-city error should be polite and informative. If you show “New York” and “New York City” as separate results, your app feels sloppy. If the wind speed is missing, your app should still render with a graceful fallback rather than leaving a blank area. These are the same concerns you’ll face in a product dashboard, a checkout screen, or an AI output viewer.

In 2026, the ecosystem also makes this app a nice testing ground for modern tooling. With Vite, the feedback loop is tight. With React function components, state modeling stays readable. With AI-assisted coding, you can draft a layout quickly but still need to confirm logic, error paths, and data contracts. I see this project as a short, realistic rehearsal for building with real APIs under real constraints.

Architecture and data flow I recommend

For a project this size, I favor a small component tree with a single data source and a clean data boundary at the API layer. The user types a city, the app requests data, and the UI renders a card. That sounds trivial, but I still outline the flow early to prevent state drift or duplicated logic.

Here’s the data flow I use:

  • User submits a city name from a controlled input.
  • App validates input (trim, non-empty).
  • App calls a fetch function with the normalized city string.
  • While waiting, UI shows a loading state.
  • On success, UI renders weather data with formatting helpers.
  • On failure, UI renders a readable error message.

I also like to add a boundary between UI and network logic, even for a small app. That boundary makes it easy to test, easy to swap APIs later, and easy to add caching without rewriting your components.

Traditional vs modern patterns show up here clearly. I recommend the modern column in 2026 unless you are maintaining legacy code.

Aspect

Traditional

Modern (Recommended) —

— Build tool

CRA or Webpack-heavy setup

Vite with React plugin Components

Class components with lifecycle

Function components with hooks Network

API calls inline in components

Dedicated API module + hooks State

Multiple local states, no memo

Coherent state object + helpers Styling

Global CSS with weak structure

Scoped class patterns + tokens

This small discipline pays off. It keeps the UI predictable and makes edge cases less painful, which is why I teach this structure first.

Project setup with Vite and modern dependencies

I start with Vite because the dev server is fast and the configuration overhead is small. You can keep dependencies minimal and still have a strong developer experience.

Create the project:

npm create vite@latest weather-react — –template react

Move into the folder and install dependencies:

cd weather-react

npm install axios

I’m keeping dependencies minimal. You can add icon libraries later, but I prefer to keep the first pass lean so you can focus on data flow. If you want icons, add Font Awesome in a later step.

Your package.json dependencies should look like this:

{

"dependencies": {

"axios": "^1.6.0",

"react": "^18.2.0",

"react-dom": "^18.2.0"

},

"devDependencies": {

"@vitejs/plugin-react": "^4.0.0",

"vite": "^5.0.0"

}

}

Next, set up the API key in an environment file. Create a .env file in the project root:

VITEOPENWEATHERAPIKEY=yourapikeyhere

I always use the VITE_ prefix because Vite exposes only variables with that prefix to client code. That keeps secrets from accidentally leaking if you name them wrong.

Core React implementation with clean state

I keep the main component small and build a dedicated API module. I also create a tiny utility module for formatting dates and numbers. This makes the UI code easy to scan and keeps the non-visual logic separate.

Here’s the file structure I recommend:

weather-react/

src/

api/

weather.js

utils/

format.js

App.jsx

main.jsx

index.css

src/main.jsx sets up the React root:

import React from "react";

import ReactDOM from "react-dom/client";

import App from "./App.jsx";

import "./index.css";

ReactDOM.createRoot(document.getElementById("root")).render(

);

src/api/weather.js holds the network call. I keep it isolated and return a normalized data shape so the UI stays stable even if the API response changes.

import axios from "axios";

const APIKEY = import.meta.env.VITEOPENWEATHERAPIKEY;

const BASE_URL = "https://api.openweathermap.org/data/2.5/weather";

export async function fetchWeatherByCity(city) {

const params = {

q: city,

units: "metric",

appid: API_KEY,

};

const response = await axios.get(BASE_URL, { params });

const data = response.data;

return {

city: data.name,

country: data.sys?.country ?? "",

temp: data.main?.temp ?? null,

feelsLike: data.main?.feels_like ?? null,

humidity: data.main?.humidity ?? null,

windSpeed: data.wind?.speed ?? null,

description: data.weather?.[0]?.description ?? "",

icon: data.weather?.[0]?.icon ?? "",

timestamp: data.dt ? data.dt * 1000 : Date.now(),

};

}

I use a format helper to keep the UI clean.

// src/utils/format.js

export function formatDate(ms) {

const date = new Date(ms);

return date.toLocaleString(undefined, {

weekday: "short",

hour: "2-digit",

minute: "2-digit",

});

}

export function formatTemp(value) {

if (value === null || value === undefined) return "–";

return Math.round(value);

}

Finally, src/App.jsx handles input, calls the API, and renders the UI. This is the piece you will most often adapt to your own projects.

import { useState } from "react";

import { fetchWeatherByCity } from "./api/weather";

import { formatDate, formatTemp } from "./utils/format";

export default function App() {

const [query, setQuery] = useState("");

const [weather, setWeather] = useState(null);

const [status, setStatus] = useState("idle"); // idle

loading

errorsuccess

const [errorMessage, setErrorMessage] = useState("");

async function handleSearch(event) {

event.preventDefault();

const city = query.trim();

if (!city) {

setErrorMessage("Please enter a city name.");

setStatus("error");

return;

}

setStatus("loading");

setErrorMessage("");

try {

const result = await fetchWeatherByCity(city);

setWeather(result);

setStatus("success");

} catch (err) {

const message = err?.response?.data?.message || "City not found.";

setErrorMessage(message);

setStatus("error");

setWeather(null);

}

}

return (

React Weather

<input

className="search-input"

type="text"

value={query}

onChange={(e) => setQuery(e.target.value)}

placeholder="Search city…"

aria-label="City name"

/>

{status === "loading" &&

Loading…

}

{status === "error" && (

{errorMessage}

)}

{status === "success" && weather && (

{weather.city}, {weather.country}

{formatDate(weather.timestamp)}

{weather.icon && (

<img

className="icon"

alt={weather.description}

src={https://openweathermap.org/img/wn/${weather.icon}@2x.png}

/>

)}

{formatTemp(weather.temp)}°C
{weather.description}
Feels like: {formatTemp(weather.feelsLike)}°C
Humidity: {weather.humidity ?? "–"}%
Wind: {weather.windSpeed ?? "–"} m/s

)}

);

}

This is fully runnable with the CSS below. The important design choice here is that the component has exactly one source of truth for status, and that status drives the UI. I avoid interleaving error and loading flags because it creates odd combinations like “loading and error at once.”

API details, errors, and rate limits

OpenWeatherMap’s current weather endpoint is straightforward, but it still requires discipline. You should expect failed lookups, country ambiguity, and rate limits that vary based on plan. I handle failures in a user-friendly way and keep the error path as clean as the success path.

I recommend these rules:

  • Treat all API calls as unreliable. Wrap calls in try/catch.
  • Always show a clear error message, not just a blank screen.
  • Normalize the data shape in the API module, not in the UI.
  • Do not store the API key in the UI code. Use .env and Vite’s prefix.

In the code above, I take the raw API response and return a consistent data shape. Even if data.wind is missing, the UI still renders. This is the core of making the app resilient.

A common issue is that the API will accept invalid city strings but still return a 404 with a message like “city not found.” I surface that message when possible but fall back to a clean default. You could also localize this message based on user language, but that is outside scope here.

If you want to prevent users from hitting rate limits, add a small delay or require explicit submit rather than sending a request on every keypress. In this app, I use a submit button for that reason. If you later add instant search, add a debounce of 300–500ms and cancel previous requests with AbortController or axios cancel tokens.

UI, responsiveness, and accessible feedback

I keep the UI clean and accessible, with a wide card that fits on desktop and shrinks on mobile. A few CSS variables keep the color palette consistent. The layout is intentionally minimal: a centered card, a search bar, and a result section. That is enough for clarity, which matters more than decoration for a data app.

Here is a complete src/index.css that matches the JSX above:

:root {

–bg: #f7f7f7;

–card: #ffffff;

–text: #222;

–muted: #6b6b6b;

–accent: #2e7d32;

–danger: #c62828;

–shadow: rgba(0, 0, 0, 0.12);

}

  • {

box-sizing: border-box;

font-family: "Segoe UI", system-ui, -apple-system, sans-serif;

}

body {

margin: 0;

background: var(–bg);

color: var(–text);

}

.app {

max-width: 680px;

margin: 72px auto;

padding: 24px;

text-align: center;

}

.app-title {

margin-bottom: 16px;

color: var(–accent);

font-size: 2.2rem;

}

.search-form {

display: flex;

gap: 8px;

justify-content: center;

margin-bottom: 20px;

}

.search-input {

width: 100%;

max-width: 360px;

padding: 12px 14px;

border: 1px solid #d0d0d0;

border-radius: 20px;

outline: none;

}

.search-input:focus {

border-color: var(–accent);

}

.search-button {

padding: 12px 16px;

border: none;

border-radius: 20px;

background: var(–accent);

color: white;

cursor: pointer;

}

.status {

color: var(–muted);

}

.error {

color: var(–danger);

font-weight: 600;

}

.card {

background: var(–card);

border-radius: 12px;

padding: 20px;

box-shadow: 0 4px 16px var(–shadow);

}

.card-header {

display: flex;

justify-content: space-between;

align-items: center;

}

.city {

font-size: 1.4rem;

margin: 0;

}

.date {

margin: 4px 0 0;

color: var(–muted);

}

.icon {

width: 64px;

height: 64px;

}

.temp-row {

margin: 16px 0;

display: flex;

flex-direction: column;

gap: 6px;

}

.temp {

font-size: 2.5rem;

font-weight: 700;

}

.desc {

text-transform: capitalize;

color: var(–muted);

}

.meta {

display: grid;

grid-template-columns: 1fr;

gap: 6px;

color: var(–muted);

}

@media (min-width: 600px) {

.meta {

grid-template-columns: repeat(3, 1fr);

}

}

I keep the typography simple and the spacing consistent so the data is the star. The button and input remain usable on small screens. The error text stands out, and the loading text is clear but not dramatic. If you want animation, a subtle fade-in on the card is enough.

Common mistakes and how I avoid them

This is the section I always review after teaching the project, because the same mistakes appear again and again. You can avoid them with a few deliberate choices.

  • Missing input validation: If you allow empty strings, you waste API calls and confuse users. I check for a trimmed string first.
  • Fragmented status state: Separate isLoading, hasError, and data flags can conflict. I prefer a single status state.
  • Rendering raw API data: This invites “Cannot read property” errors. Normalize fields in the API module, not in the UI.
  • Unhandled race conditions: If a user submits twice quickly, the slower response can overwrite the newer one. Use a request ID guard or cancellation.
  • Forgetting units: APIs often default to Kelvin or imperial. Explicitly pass units or you’ll get confusing temperatures.
  • Unhelpful error messages: “Something went wrong” isn’t enough. Use a friendly message with a hint.

If you fix these six issues, your app feels significantly more mature.

Deepening the data model (what to include and why)

The basic fields are enough for a demo, but the moment you show this app to a friend, they ask for “high/low today” or “chance of rain.” That’s where a simple design choice becomes important: do you extend the current endpoint or switch to a forecast endpoint? For a single-city app, I keep the current endpoint for speed, but I design my data model so it can grow.

Here’s a more robust normalized shape that scales without inflating UI complexity:

{

city: "San Francisco",

country: "US",

temp: 16.2,

feelsLike: 14.9,

tempMin: 12.8,

tempMax: 17.4,

humidity: 73,

windSpeed: 4.1,

description: "overcast clouds",

icon: "04d",

sunrise: 1706623500,

sunset: 1706660012,

timestamp: 1706641200

}

Even if you don’t render all of these, the model lets you add features without rewriting the fetch function. I often include sunrise and sunset because they’re easy to display and feel “premium.” It’s a small bump in perceived quality with minimal extra UI.

If you do show min/max, keep it tight:

  • Show both near the main temperature.
  • Use a smaller font.
  • Include a degree symbol and consistent units.

This keeps the design compact and the mental load low.

Handling ambiguous city names (and being polite about it)

Cities are messy. “Springfield” exists in multiple countries and states. When an API returns the first match, the user sometimes gets the wrong location without realizing it. There are a few ways to handle this gracefully:

1) Accept ambiguity but display country and state clearly. This is the simplest and often enough.

2) Use an autocomplete endpoint to list possible matches.

3) Allow users to append a country code (e.g., “Springfield, US”) and explain that in the UI.

For this app, I keep it simple: I show the country next to the city name and mention in the help text that they can append a country code.

A small line below the input helps:

“Tip: add a country code (e.g., Paris, FR) if you see the wrong city.”

That line can reduce confusion without adding complex UI. It’s a small usability win.

Improving the API layer with cancellation and timeouts

In fast demos, cancellation is optional. In real usage, it matters. If a request hangs because the network drops, your UI can sit in a loading state forever. I like to handle this with a timeout and an abort controller. Axios supports cancellation, but the simplest modern approach is using fetch with AbortController. If you want to stay with axios, you can still apply a timeout and cancel token.

Here’s a fetch-based alternative that is light and clear:

const APIKEY = import.meta.env.VITEOPENWEATHERAPIKEY;

const BASE_URL = "https://api.openweathermap.org/data/2.5/weather";

export async function fetchWeatherByCity(city, { signal } = {}) {

const params = new URLSearchParams({

q: city,

units: "metric",

appid: API_KEY,

});

const response = await fetch(${BASE_URL}?${params.toString()}, { signal });

if (!response.ok) {

const errorBody = await response.json().catch(() => ({}));

const message = errorBody?.message || "City not found.";

throw new Error(message);

}

const data = await response.json();

return {

city: data.name,

country: data.sys?.country ?? "",

temp: data.main?.temp ?? null,

feelsLike: data.main?.feels_like ?? null,

humidity: data.main?.humidity ?? null,

windSpeed: data.wind?.speed ?? null,

description: data.weather?.[0]?.description ?? "",

icon: data.weather?.[0]?.icon ?? "",

timestamp: data.dt ? data.dt * 1000 : Date.now(),

};

}

Then in your component:

import { useRef } from "react";

const controllerRef = useRef(null);

async function handleSearch(event) {

event.preventDefault();

const city = query.trim();

if (!city) {

setErrorMessage("Please enter a city name.");

setStatus("error");

return;

}

controllerRef.current?.abort();

const controller = new AbortController();

controllerRef.current = controller;

setStatus("loading");

setErrorMessage("");

try {

const result = await fetchWeatherByCity(city, { signal: controller.signal });

setWeather(result);

setStatus("success");

} catch (err) {

if (err.name === "AbortError") return;

setErrorMessage(err.message || "City not found.");

setStatus("error");

setWeather(null);

}

}

This prevents stale results and avoids “double response” flicker. It’s a small change but helps the app feel professional.

A better UX with keyboard, focus, and feedback

A weather app seems trivial, but it’s still a form-based experience. Tiny UX details separate a “toy app” from a usable one:

  • Auto-focus the input on page load so users can type instantly.
  • Use aria-live for status updates so screen readers hear changes.
  • Disable the search button during loading to prevent accidental resubmits.
  • Provide a subtle helper line that explains the expected input.

Here’s how I apply these in the React component:

<input

className="search-input"

type="text"

value={query}

onChange={(e) => setQuery(e.target.value)}

placeholder="Search city…"

aria-label="City name"

autoFocus

/>

{status === "loading" &&

Loading…

}

{status === "error" &&

{errorMessage}

}

This doesn’t add complexity, but it makes the app kinder and faster to use.

Advanced formatting for clarity (time, units, and localization)

Most apps fail on details like units. If your audience includes US users, they expect Fahrenheit and miles per hour. If your audience is global, you need a unit toggle or a reasonable default. I prefer a simple toggle that stores a preference in localStorage. It keeps the code small but adds a lot of perceived polish.

Here’s a tiny unit toggle concept:

const [units, setUnits] = useState("metric");

function toggleUnits() {

setUnits((prev) => (prev === "metric" ? "imperial" : "metric"));

}

In the fetch call:

const params = {

q: city,

units,

appid: API_KEY,

};

In the UI:

You can also format based on locale using Intl.NumberFormat. This avoids hardcoding decimals:

export function formatNumber(value, options = {}) {

if (value === null || value === undefined) return "–";

return new Intl.NumberFormat(undefined, options).format(value);

}

Then you can do:

Wind: {formatNumber(weather.windSpeed, { maximumFractionDigits: 1 })} {units === "metric" ? "m/s" : "mph"}

This is a subtle quality boost and a good habit for all data-heavy UIs.

Edge cases that matter in real use

Most demos skip edge cases, which is exactly why real apps break. Here are the ones I’ve seen in production or workshops:

  • Empty response fields: Some locations may not include wind or weather[0]. Your UI must still render without crashing.
  • Unrecognized characters in city names: Users type accents or symbols. Always encode query parameters with URLSearchParams or axios params.
  • Rate-limited responses: The API can return a 429 status. You should show a “please try again later” message.
  • Very cold or hot temperatures: Check your formatting and avoid weird spacing around the degree symbol.
  • Network off: If the user is offline, show a specific message rather than a generic error.

You can handle offline detection with a quick check:

if (!navigator.onLine) {

setErrorMessage("You appear to be offline. Please check your connection.");

setStatus("error");

return;

}

This is optional but helps users understand what’s wrong.

Performance considerations that actually matter

You don’t need a massive performance strategy for a single-page weather app, but you should still avoid common pitfalls. The biggest ones:

  • Re-rendering the entire UI on every keypress: In React, this is normal, but don’t make heavy calculations in the render path.
  • Uncached network calls: If a user searches the same city twice, you can cache results to avoid unnecessary requests.
  • Over-fetching: Don’t fetch on each keystroke unless it’s intentional and debounced.

A simple cache can live in memory:

const cacheRef = useRef(new Map());

async function handleSearch(event) {

event.preventDefault();

const city = query.trim();

if (!city) return;

if (cacheRef.current.has(city.toLowerCase())) {

setWeather(cacheRef.current.get(city.toLowerCase()));

setStatus("success");

return;

}

setStatus("loading");

try {

const result = await fetchWeatherByCity(city);

cacheRef.current.set(city.toLowerCase(), result);

setWeather(result);

setStatus("success");

} catch (err) {

setStatus("error");

}

}

This is not persistent, but it cuts unnecessary calls and feels instant when the user repeats a query.

If you want a more durable cache, store it in localStorage with a timestamp and clear it after a short TTL (say 10–30 minutes). That’s enough to be helpful without risking stale data.

Practical scenarios: when to use and when not to use this pattern

A weather app is a microcosm of API-driven UI. The same pattern applies to many real-world products.

Use this approach when:

  • You have a single API source and you want a clean, predictable UI.
  • You want to teach or learn the fundamentals of async state handling.
  • You need a quick prototype that can scale into a larger app later.

Don’t use this exact pattern when:

  • You need multi-step data dependencies (you’ll want a data-fetching library).
  • You have heavy caching requirements (you’ll want a cache or query system).
  • You need global app state beyond a single screen (you’ll want a state manager or context).

The architecture here is intentionally small. It teaches the right habits while keeping the code understandable for beginners and intermediate devs.

Alternative approaches you might consider

There are several ways to structure this project. I prefer the minimal approach, but these alternatives are worth knowing.

1) React Query (TanStack Query)

  • Pros: Automatic caching, retries, background updates.
  • Cons: Adds a dependency and introduces a new mental model.
  • When to use: When you have multiple API calls and want automatic cache behavior.

2) Zustand or Redux

  • Pros: Centralized state, easy cross-component sharing.
  • Cons: Overkill for a single page.
  • When to use: When your weather data is part of a larger app with other features.

3) Next.js or Remix

  • Pros: Built-in routing, data fetching patterns, deployment integrations.
  • Cons: More complexity than a simple Vite app.
  • When to use: When you need SEO, routing, or SSR.

I stay with Vite and local state for this article because it keeps the focus on fundamentals. But you can evolve the app into any of these patterns once the core is stable.

Testing the core logic without overdoing it

Even a small app benefits from basic tests. You don’t need a full test suite, but you should validate your data formatting and error handling. I like to test the formatting helpers and the API normalization logic because these are the most likely to break when APIs change.

Example tests (conceptual):

  • formatTemp returns “–” for null.
  • formatDate returns a valid string for a timestamp.
  • The API normalization returns all expected keys with fallbacks.

You can do this with a light test setup or even just manual checks. The point is to avoid silent failures when the API evolves.

Security and key management notes

Even though the app is client-only, you should still treat the API key as sensitive. It will be exposed in the client bundle, but you can keep it out of source control and avoid accidental leaks.

Practical reminders:

  • Keep .env out of git.
  • Use VITE_ prefixes only for keys you intend to expose.
  • If you move to a backend, proxy the request and keep the real key server-side.

For a learning project, this is acceptable. For production, consider a small serverless proxy or backend route to protect keys and enforce rate limits.

Production considerations: deployment and monitoring

A simple app can still be deployed cleanly and monitored with basic tooling.

Deployment tips:

  • Build with npm run build and host the dist folder on a static host.
  • Configure environment variables on the host for the API key.
  • Set a proper cache policy for static assets.

Monitoring tips:

  • Add basic error logging for failed API calls if this becomes a real product.
  • Track usage to understand which cities are queried most often.
  • Watch for 429 errors to detect rate limit issues.

This is not required for a tutorial project, but it’s how you evolve it into something reliable.

A simple mental model for future expansions

When you expand the app later, use this mental model to avoid sprawl:

1) Data: Normalize the API response into a stable shape.

2) State: Keep a single “source of truth” for status.

3) UI: Render by state, not by data existence.

4) UX: Make errors explicit and recoverable.

5) Performance: Avoid unnecessary fetches and stale responses.

If you stick to this, you can add forecasts, multi-city tabs, or even maps without losing clarity.

A more feature-complete example (still compact)

Below is a slightly richer App component that includes unit toggling, a helper line, and disabled button behavior. It stays compact but feels more complete.

import { useRef, useState } from "react";

import { fetchWeatherByCity } from "./api/weather";

import { formatDate, formatTemp } from "./utils/format";

export default function App() {

const [query, setQuery] = useState("");

const [weather, setWeather] = useState(null);

const [status, setStatus] = useState("idle");

const [errorMessage, setErrorMessage] = useState("");

const [units, setUnits] = useState("metric");

const controllerRef = useRef(null);

function toggleUnits() {

setUnits((prev) => (prev === "metric" ? "imperial" : "metric"));

}

async function handleSearch(event) {

event.preventDefault();

const city = query.trim();

if (!city) {

setErrorMessage("Please enter a city name.");

setStatus("error");

return;

}

if (!navigator.onLine) {

setErrorMessage("You appear to be offline. Please check your connection.");

setStatus("error");

return;

}

controllerRef.current?.abort();

const controller = new AbortController();

controllerRef.current = controller;

setStatus("loading");

setErrorMessage("");

try {

const result = await fetchWeatherByCity(city, { units, signal: controller.signal });

setWeather(result);

setStatus("success");

} catch (err) {

if (err.name === "AbortError") return;

setErrorMessage(err.message || "City not found.");

setStatus("error");

setWeather(null);

}

}

return (

React Weather

Tip: add a country code (e.g., Paris, FR) if needed.

<input

className="search-input"

type="text"

value={query}

onChange={(e) => setQuery(e.target.value)}

placeholder="Search city…"

aria-label="City name"

autoFocus

/>

{status === "loading" &&

Loading…

}

{status === "error" &&

{errorMessage}

}

{status === "success" && weather && (

{weather.city}, {weather.country}

{formatDate(weather.timestamp)}

{weather.icon && (

<img

className="icon"

alt={weather.description}

src={https://openweathermap.org/img/wn/${weather.icon}@2x.png}

/>

)}

{formatTemp(weather.temp)}°{units === "metric" ? "C" : "F"}
{weather.description}
Feels like: {formatTemp(weather.feelsLike)}°{units === "metric" ? "C" : "F"}
Humidity: {weather.humidity ?? "–"}%
Wind: {weather.windSpeed ?? "–"} {units === "metric" ? "m/s" : "mph"}

)}

);

}

This version still feels small, but it gives you a production-like experience: units, offline handling, cancellation, better UX. It’s a good midpoint between a tutorial and a real app.

Comparison table: minimal vs feature-complete

This is how I frame the tradeoffs for students who are deciding whether to stop at a demo or build more.

Feature

Minimal App

Feature-Complete Version —

— Input validation

Basic

Basic + offline check Units toggle

No

Yes Request cancellation

No

Yes Cache

No

Optional Accessibility

Basic

Better ARIA, focus API layer

Simple

Slightly enhanced

You can choose the level that matches your learning goals and time. There’s no wrong choice, but it helps to be intentional.

Final thoughts

A weather application isn’t just about temperature. It’s a compact rehearsal for the real problems you face in front-end work: unstable networks, ambiguous input, and asynchronous updates. The patterns you apply here — a clear data boundary, a single state machine for UI, and thoughtful error handling — scale to any product interface.

If you take one thing from this guide, let it be this: treat your data as untrusted and your UI as a conversation. When the app shows a helpful message instead of a blank state, users feel respected. When the UI updates smoothly without flicker, users trust the product. Those are the outcomes that make a simple weather app worth building.

From here, you can extend the app with forecasts, saved locations, or even a map view. But you don’t need any of that to learn the most important lesson: clean data flow and clear UX win every time.

If you want, I can also provide a second version with a multi-day forecast and a favorites list, or a Next.js port with server-side caching.

Scroll to Top