Angular

Lab 7: HTTP & Observables

arrow_back Alle labs
timer 25 minutter
sell module-7-start
Mål: Hent data fra en ekstern JSON-fil via Angulars HttpClient, lær hvad en Observable er, og brug toSignal() til at bygge bro mellem Observables og Signals.

Hvad er en Observable?

Lige nu er alle emissionsfaktorer hardcoded inde i Emission servicen. I den virkelige verden kommer data næsten altid fra en server. Til det bruger Angular HttpClient, som er Angulars indbyggede måde at lave HTTP requests på (GET, POST, PUT osv.).

Når du kalder http.get(), får du ikke data med det samme. Du får i stedet en Observable. Tænk på det som et løfte om data der kommer på et tidspunkt. En Observable er som en avis-abonnement: du tilmelder dig (subscribe), og når avisen er klar, bliver den leveret til dig.

Det smarte er at Angular har en funktion kaldet toSignal() der kan konvertere en Observable til et Signal. Så får du det bedste fra begge verdener: HttpClient til at hente data, og Signals til at reagere på ændringer i din template.

Udgangspunkt

Checkout tag module-7-start. Servicen har en hardcoded herdCards array. Vi skal flytte den data ud i en JSON-fil og hente den med HttpClient.

git checkout module-7-start

Trin for trin

looks_one Opret en JSON-datafil

Opret filen public/emission-factors.json med alle emissionsfaktorerne. Filer i public/ mappen bliver serveret direkte af dev-serveren, så vi kan hente dem via HTTP:

// public/emission-factors.json
[
  { "key": "dairyCows", "label": "Malkekøer", "emoji": "🐄", "factor": 3000 },
  { "key": "beefCows", "label": "Kødkvæg", "emoji": "🐂", "factor": 2400 },
  { "key": "calves", "label": "Kalve & Ungdyr", "emoji": "🐃", "factor": 1000 },
  { "key": "pigs", "label": "Slagtesvin", "emoji": "🐖", "factor": 50 },
  { "key": "sows", "label": "Søer", "emoji": "🐗", "factor": 450 },
  { "key": "chickens", "label": "Slagtekyllinger", "emoji": "🐓", "factor": 1.5 }
]

looks_two Konfigurer HttpClient

Før du kan bruge HttpClient, skal Angular vide at den skal stille den til rådighed. Åbn app.config.ts og tilføj provideHttpClient(withFetch()):

// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideHttpClient(withFetch()),
  ]
};

provideHttpClient() registrerer HttpClient i Angulars DI-system. withFetch() fortæller Angular at den skal bruge browserens moderne fetch() API under motorhjelmen (i stedet for det ældre XMLHttpRequest).

looks_3 Opdater Emission servicen

Nu skal vi ændre servicen så den henter data fra JSON-filen i stedet for at have den hardcoded. Her sker det interessante:

  1. Inject HttpClient med inject()
  2. Hent data med http.get() — det returnerer en Observable
  3. Konverter til Signal med toSignal() — så det passer ind i vores signal-baserede arkitektur
// calculator/emission.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

// ... interfaces uændrede ...

@Injectable({ providedIn: 'root' })
export class Emission {
  private readonly http = inject(HttpClient);

  readonly herdCards = toSignal(
    this.http.get<Card[]>('/emission-factors.json'),
    { initialValue: [] }
  );

  // ... resten uændret ...
}

Lad os forstå hvad der sker:

lightbulb
Vigtigt: Fordi herdCards nu er et Signal (ikke et almindeligt array), skal du kalde det med () de steder det bruges. F.eks. i totalEmission computed: this.herdCards() i stedet for this.herdCards.

Opdater også totalEmission så den kalder herdCards() som et signal:

readonly totalEmission = computed(() => {
  const counts = this.herdCounts();
  const cards = this.herdCards();  // ← kald som signal!
  return cards.reduce(
    (sum, c) => sum + counts[c.key] * c.factor, 0
  ) / 1000;
});

looks_4 Opdater components der bruger herdCards

Fordi herdCards nu er et Signal, skal du også opdatere HerdInput. I templaten skal du kalde signalet med ():

// I herd-input.ts template
@for (card of herdCards(); track card.key) {
  <app-herd-card
    [herd]="card"
    (countChanged)="onCountChanged(card.key, $event)" />
}

looks_5 Test at det virker

Start dev-serveren og åbn appen i browseren:

ng serve

Appen skal se ud præcis som før. Men åbn DevTools → Network tab og reload siden. Nu kan du se en HTTP request til /emission-factors.json. Dataen kommer fra serveren i stedet for at være hardcoded!

lightbulb
Prøv dette: Åbn public/emission-factors.json og ændr en faktor (f.eks. sæt dairyCows til 9999). Reload browseren og se at beregningen ændrer sig. Dataen er nu ekstern og kan ændres uden at kompilere appen igen!

looks_6 Tilføj fejlhåndtering

Hvad sker der hvis serveren ikke kan levere data? Lad os finde ud af det:

  1. Slet filen public/emission-factors.json
  2. Reload appen i browseren
  3. Åbn DevTools → Console og se fejlen (404 Not Found)

Appen crasher stille og viser ingen kort. Det er dårlig brugeroplevelse. Lad os tilføje fejlhåndtering med catchError:

// calculator/emission.ts
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';

// I Emission klassen:
readonly error = signal<string | null>(null);

readonly herdCards = toSignal(
  this.http.get<Card[]>('/emission-factors.json').pipe(
    catchError(() => {
      this.error.set('Kunne ikke hente emissionsdata');
      return of([] as Card[]);
    })
  ),
  { initialValue: [] }
);

Her bruger vi pipe() til at tilføje operatorer på Observablen:

Nu kan du vise fejlbeskeden i f.eks. HerdInput templaten:

// herd-input.ts template
@if (emissionService.error()) {
  <p class="error">⚠️ {{ emissionService.error() }}</p>
}
@for (card of herdCards(); track card.key) { ... }
lightbulb
Bemærk: For at tilgå emissionService.error() i templaten skal propertyen ikke være private. Skift den til readonly (uden private) eller opret en lokal reference.

filter_7 Gendan filen og bekræft

Opret public/emission-factors.json igen (kopier fra trin 1) og reload appen. Alt skal virke som normalt, og fejlbeskeden forsvinder.

Hvad lærte vi?

Hints

Hint: Komplet emission.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';

export interface HerdCounts {
  dairyCows: number;
  beefCows: number;
  calves: number;
  pigs: number;
  sows: number;
  chickens: number;
}

export interface Card {
  key: keyof HerdCounts;
  label: string;
  emoji: string;
  factor: number;
}

@Injectable({ providedIn: 'root' })
export class Emission {
  private readonly http = inject(HttpClient);

  readonly error = signal<string | null>(null);

  readonly herdCards = toSignal(
    this.http.get<Card[]>('/emission-factors.json').pipe(
      catchError(() => {
        this.error.set('Kunne ikke hente emissionsdata');
        return of([] as Card[]);
      })
    ),
    { initialValue: [] }
  );

  readonly herdCounts = signal<HerdCounts>({
    dairyCows: 0,
    beefCows: 0,
    calves: 0,
    pigs: 0,
    sows: 0,
    chickens: 0,
  });

  readonly totalEmission = computed(() => {
    const counts = this.herdCounts();
    const cards = this.herdCards();
    return cards.reduce(
      (sum, c) => sum + counts[c.key] * c.factor, 0
    ) / 1000;
  });

  updateCount(key: keyof HerdCounts, count: number) {
    this.herdCounts.update(prev => ({ ...prev, [key]: count }));
  }
}
Hint: Komplet app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideHttpClient(withFetch()),
  ]
};
Hint: Komplet herd-input.ts med fejlhåndtering
import { Component, inject } from '@angular/core';
import { HerdCard } from './herd-card/herd-card';
import { Emission, HerdCounts } from '../emission';

@Component({
  selector: 'app-herd-input',
  imports: [HerdCard],
  template: `
    @if (emissionService.error()) {
      <p class="error">⚠️ {{ emissionService.error() }}</p>
    }
    @for (card of herdCards(); track card.key) {
      <app-herd-card
        [herd]="card"
        (countChanged)="onCountChanged(card.key, $event)" />
    }
  `,
})
export class HerdInput {
  readonly emissionService = inject(Emission);

  readonly herdCards = this.emissionService.herdCards;

  onCountChanged(key: keyof HerdCounts, count: number) {
    this.emissionService.updateCount(key, count);
  }
}

Dokumentation