Skip to content
Angular projects 5 min read

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 legacy XMLHttpRequest backend for the modern fetch API. 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 switchMap for search-as-you-type to cancel stale requests; reach for concatMap or mergeMap only when you genuinely need every result.
  • Always pair debounceTime with distinctUntilChanged to avoid redundant calls and respect free-tier rate limits.
  • Catch errors inside the inner switchMap pipe so a failure never completes the outer stream and breaks future searches.
  • Prefer toSignal over manual subscribe to 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.
Last updated June 14, 2026
Was this helpful?