Weather Dashboard
A weather dashboard is the ideal second project for practicing real-world data fetching. You will call a public REST API, model the JSON response with TypeScript interfaces, drive the UI from a search box using RxJS operators, and handle the loading, error, and success states that every networked app must cope with. By the end you will have a reactive component that turns a city name into a live forecast without a single manual subscription leak.
This walkthrough uses OpenWeatherMap, a free API that returns current conditions for any city. Sign up for a key and keep it in an environment file. Everything here is modern Angular: standalone components, signals, inject(), and the new control-flow syntax.
Scaffolding the project
Generate a standalone app and a service for the data layer. Keeping HTTP logic in a service keeps the component focused on presentation.
ng new weather-dashboard --standalone --routing --style=css
cd weather-dashboard
ng generate service weather
ng generate component weather-dashboard
HttpClient is not provided by default in a standalone app, so register it in the application config with provideHttpClient.
// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withFetch())],
};
withFetch()swaps the legacyXMLHttpRequestbackend for the modernfetchAPI. It is the recommended default and is required for server-side rendering.
Modeling the response
Type the shape of the data you consume so the rest of the app gets autocomplete and compile-time safety. The OpenWeatherMap payload is large; declare only the fields you render.
// src/app/weather.model.ts
export interface WeatherResponse {
name: string;
main: { temp: number; feels_like: number; humidity: number };
weather: { id: number; main: string; description: string; icon: string }[];
wind: { speed: number };
}
export interface Weather {
city: string;
temp: number;
feelsLike: number;
humidity: number;
description: string;
icon: string;
windSpeed: number;
}
The data service
The service owns the URL, the API key, and the transformation from raw JSON into the clean Weather model. Returning a mapped, typed observable means the component never touches the API’s wire format.
// src/app/weather.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { catchError, map, Observable, throwError } from 'rxjs';
import { WeatherResponse, Weather } from './weather.model';
@Injectable({ providedIn: 'root' })
export class WeatherService {
private http = inject(HttpClient);
private baseUrl = 'https://api.openweathermap.org/data/2.5/weather';
private apiKey = 'YOUR_API_KEY'; // move to environment.ts
getWeather(city: string): Observable<Weather> {
const params = new HttpParams()
.set('q', city)
.set('units', 'metric')
.set('appid', this.apiKey);
return this.http.get<WeatherResponse>(this.baseUrl, { params }).pipe(
map((res) => this.toWeather(res)),
catchError((err: HttpErrorResponse) => this.handleError(err)),
);
}
private toWeather(res: WeatherResponse): Weather {
return {
city: res.name,
temp: Math.round(res.main.temp),
feelsLike: Math.round(res.main.feels_like),
humidity: res.main.humidity,
description: res.weather[0]?.description ?? 'unknown',
icon: res.weather[0]?.icon ?? '01d',
windSpeed: res.wind.speed,
};
}
private handleError(err: HttpErrorResponse) {
const message =
err.status === 404
? 'City not found. Check the spelling.'
: err.status === 0
? 'Network error — please check your connection.'
: `Weather service error (${err.status}).`;
return throwError(() => new Error(message));
}
}
HttpParams builds the query string safely with proper URL encoding, so a city like "São Paulo" is escaped correctly.
Reactive search with RxJS
Rather than firing a request on every keystroke, pipe the search input through RxJS operators. debounceTime waits for a pause in typing, distinctUntilChanged ignores duplicate values, and switchMap cancels any in-flight request when a newer search begins — eliminating race conditions where a slow earlier response overwrites a fresh one.
// src/app/weather-dashboard.component.ts
import { Component, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import {
catchError, debounceTime, distinctUntilChanged, filter,
map, of, switchMap, tap,
} from 'rxjs';
import { WeatherService } from './weather.service';
import { Weather } from './weather.model';
@Component({
selector: 'app-weather-dashboard',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './weather-dashboard.component.html',
})
export class WeatherDashboardComponent {
private service = inject(WeatherService);
search = new FormControl('', { nonNullable: true });
loading = signal(false);
error = signal<string | null>(null);
weather = toSignal(
this.search.valueChanges.pipe(
debounceTime(400),
map((v) => v.trim()),
distinctUntilChanged(),
filter((city) => city.length > 2),
tap(() => {
this.loading.set(true);
this.error.set(null);
}),
switchMap((city) =>
this.service.getWeather(city).pipe(
catchError((e: Error) => {
this.error.set(e.message);
return of(null);
}),
),
),
tap(() => this.loading.set(false)),
),
{ initialValue: null as Weather | null },
);
}
toSignal bridges the observable into a signal and manages the subscription lifecycle for you — it unsubscribes automatically when the component is destroyed, so there is no ngOnDestroy boilerplate.
The reactive template
The template reads the signals with the new control-flow blocks. Each UI state — typing prompt, loading, error, and result — maps to a clear branch.
<!-- weather-dashboard.component.html -->
<input [formControl]="search" placeholder="Search a city…" />
@if (loading()) {
<p>Loading…</p>
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else if (weather(); as w) {
<article class="card">
<h2>{{ w.city }}</h2>
<img
[src]="'https://openweathermap.org/img/wn/' + w.icon + '@2x.png'"
[alt]="w.description"
/>
<p class="temp">{{ w.temp }}°C</p>
<p>Feels like {{ w.feelsLike }}°C — {{ w.description }}</p>
<p>Humidity {{ w.humidity }}% · Wind {{ w.windSpeed }} m/s</p>
</article>
} @else {
<p>Type at least 3 letters to search.</p>
}
Typing “london” produces a single request after the debounce window and renders the card.
Output:
London
12°C
Feels like 10°C — broken clouds
Humidity 78% · Wind 4.1 m/s
Best practices
- Keep the API key out of source control: read it from
environment.ts(and inject the real value at build time) rather than hard-coding it in the service. - Transform the raw response into a domain model inside the service so components never depend on the third-party JSON shape.
- Use
switchMapfor search-as-you-type to cancel stale requests; reach forconcatMapormergeMaponly when you genuinely need every result. - Always pair
debounceTimewithdistinctUntilChangedto avoid redundant calls and respect free-tier rate limits. - Catch errors inside the inner
switchMappipe so a failure never completes the outer stream and breaks future searches. - Prefer
toSignalover manualsubscribeto get automatic teardown and a template-friendly, synchronous value. - Render explicit loading and error states — a networked UI that only handles the happy path feels broken the moment the connection hiccups.