Startseite / Artikel

Ersetzen der Ladeflaggen in Angular durch eine diskriminierte AsyncState-Union

Modellieren Sie asynchrone Operationen in Angular als eine typisierte Union, erzeugen Sie Boolesche Werte mit computed Signals und steuern Sie jeden Zustandswechsel über einen einzigen RxJS-Operator.

Verlag: REACTAPP.TOP11 Min. Lesezeit2637 Wörter

Quellen: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular

Fast jedes Angular-Komponente, das eine API aufruft, verfolgt seinen Fortschritt mithilfe einiger Wahrheitswerte: einer für das Laden, einer für Fehler und einer pro Schaltfläche, die gerade in Benutzung ist. Jeder dieser Werte macht für sich genommen Sinn, doch zusammen führen sie dazu, dass die Komponente Zustände erreicht, die niemand ursprünglich vorgesehen hat, und das Template verwandelt sich in eine manuell erstellte Wahrheitstabelle. Diese Anleitung zeigt, wie man diese Werte durch eine einzige diskriminierte Union pro asynchrone Operation ersetzen kann, die noch benötigten Wahrheitswerte mit computed erzeugen, alle Übergänge in einen einzigen kleinen RxJS-Operator überführen und das Ergebnis mit @switch darstellen lässt, sodass der Typprüfer des Templates sicherstellt, was im UI angezeigt werden darf.

Die Wahrheitswert-Version und warum sie scheinbar in Ordnung ist

Hier ist ein typisches Bestellbildschirm, der bei Initialisierung eine Liste lädt und einen Bezahlfeld-Button anbietet. Zählen Sie die Felder, die nur zum Verfolgen des Fortschritts existieren:

@Component({ /* ... */ })
export class OrdersComponent {
  isLoading = false;
  isRefreshing = false;
  isCheckingOut = false;
  isCancelling = false;
  hasError = false;
  errorMessage: string | null = null;
  orders: Order[] = [];

  ngOnInit(): void {
    this.isLoading = true;
    this.api.getOrders().subscribe({
      next: (orders) => {
        this.orders = orders;
        this.isLoading = false;
      },
      error: (err) => {
        this.hasError = true;
        this.errorMessage = err.message;
        this.isLoading = false;
      },
    });
  }

  checkout(): void {
    this.isCheckingOut = true;
    this.hasError = false;
    this.api.checkout(this.orders).subscribe({
      next: () => (this.isCheckingOut = false),
      error: (err) => {
        this.isCheckingOut = false;
        this.hasError = true;
        this.errorMessage = err.message;
      },
    });
  }
}

Das passende Template muss diese Flags kombinieren, um zu entscheiden, was angezeigt werden soll, und der Checkout-Button muss über jede laufende Operation Bescheid wissen:

@if (isLoading) { <app-spinner /> }
@if (!isLoading && hasError) { <app-error [message]="errorMessage" /> }
@if (!isLoading && !hasError && orders.length === 0) { <app-empty /> }
@if (!isLoading && !hasError && orders.length > 0) { <app-order-list [orders]="orders" /> }

<button [disabled]="isLoading || isRefreshing || isCheckingOut || isCancelling">
  Checkout
</button>

Keines davon ist fehlerhaft, und es würde den meisten Überprüfungen standhalten. Das Problem liegt im Wachstum: Jede neue Funktion fügt ein weiteres Flag hinzu.

Warum unabhängige Flags ausschließliche Zustände nicht beschreiben können

Fünf Boolesche Werte ergeben 2 hoch 5, also 32 mögliche Kombinationen. Der Bildschirm wurde für etwa fünf davon konzipiert: inaktiv, lädt, geladen, leer und fehlgeschlagen. Die verbleibenden 27 sind zur Laufzeit erreichbar, und das Typensystem hat keine Möglichkeit, festzustellen, dass sie ungültig sind. Dieser Unterschied zeigt sich auf vertraute Weise:

  • Konträre Zustände sind zulässig. Nichts hindert daran, dass sowohl isLoading als auch hasError den Wert true annehmen. Was der Benutzer dann sieht, hängt von der Reihenfolge der @if-Blöcke ab – dadurch können ein Ladeindikator und eine Fehleranzeige gleichzeitig auf dem Bildschirm angezeigt werden.
  • Das Template wird zu einer Wahrheitstabelle. Eine Bedingung wie !isLoading && !hasError && orders.length > 0 entspricht einer Zeile in einer nie schriftlich festgehaltenen Tabelle, und jedes neue Flag erhöht die Anzahl der Zeilen.
  • Funktionen erweitern die Klasse seitlich. „Pull-to-Refresh“ fügt isRefreshing hinzu; ein optimistischer Abbruch fügt isCancelling sowie vermutlich cancellingId hinzu. Das tatsächliche Verhalten der Komponente geht dabei unter den Buchhaltungsfunktionen verloren.
  • Die Tests überprüfen Flags anstelle des Verhaltens. Die Assertionen lauten schließlich isLoading === false && hasError === true anstelle von „der Benutzer sieht eine Fehlermeldung“.
  • Die zugrunde liegende Ursache ist ein Modellierungsfehler. Die Flags sind voneinander unabhängig, doch die Zustände, die sie beschreiben sollen, sind gegenseitig ausschließend. Wenn der Typ nicht zum Domänenkontext passt, entstehen Fehler. Ändert man den Typ, verschwinden die meisten dieser Symptome.

    Schritt 1: Den Zustand als einzigen Wert darstellen

    Eine diskriminierte Union erfassen die Ausschließlichkeit direkt. Jede Variante enthält einen status-Literal, und Payloads existieren nur in den Varianten, in denen sie sinnvoll sind:

    import { HttpErrorResponse } from '@angular/common/http';
    
    export type AsyncState<T, E = HttpErrorResponse> =
      | { readonly status: 'idle' }
      | { readonly status: 'loading' }
      | { readonly status: 'success'; readonly data: T }
      | { readonly status: 'error'; readonly error: E };
    

    Nur ein status kann gleichzeitig gespeichert werden, und data ist nur dann vorhanden, wenn der Status 'success' ist. Was die Booleschen Werte bisher nur andeuteten, wird nun vom Compiler durchgesetzt.

    Kleine Konstruktorfunktionen sorgen dafür, dass die Aufrufstellen lesbar bleiben, und ersparen Ihnen das Schreiben von Zeichenkettenliteralen überall. Ein Prädikat wie isLoading ist später nützlich für abgeleitete Werte:

    export const idle = () => ({ status: 'idle' }) as const;
    export const loading = () => ({ status: 'loading' }) as const;
    export const success = <T>(data: T) => ({ status: 'success', data }) as const;
    export const failure = <E>(error: E) => ({ status: 'error', error }) as const;
    
    export const isLoading = (state: AsyncState<unknown, unknown>): boolean =>
      state.status === 'loading';
    

    Die as const-Aussagen stellen sicher, dass jeder Konstruktor einen literalen status-Typ zurückgibt und nicht eine erweiterte string, was es ermöglicht, diese Objekte einer Union zuzuweisen.

    Warum eine Schnittstelle mit optionalen Feldern nicht ausreicht

    Ein häufiger erster Versuch sieht so aus:

    interface LoadState<T> {
      loading: boolean;
      data: T | null;
      error: HttpErrorResponse | null;
    }
    

    Es handelt sich um dieselbe Gruppe unabhängiger Flags, die in einem benannten Typ verpackt sind. Nichts hindert { loading: true, data: [...], error: {...} } daran, wodurch jeder Nutzer weiterhin raten muss, welches Feld als autoritativ gilt. Der Unterscheidungsfaktor ist das wesentliche Element – ohne ihn hat man das Problem nur verlagert, anstatt es zu lösen.

    Schritt 2: Ein Zustand pro asynchrone Operation

    Geben Sie jeder Operation ihren eigenen AsyncState, anstatt für jedes Problemfeld ein Paar Boolesche Werte zu verwenden:

    readonly orders = signal<AsyncState<Order[]>>(idle());
    readonly checkout = signal<AsyncState<Receipt>>(idle());
    

    Die Anzahl der Zustandsfelder entspricht nun der Anzahl der gleichzeitig laufenden Operationen, was die genaue Zahl darstellt – anstatt für jede mögliche Fehlerquelle ein eigenes Flag zu verwenden.

    Schritt 3: Boolesche Werte ableiten statt sie zu speichern

    Templates benötigen weiterhin Boolesche Werte. [disabled] erfordert einen solchen Wert, und die Frage „Ist etwas in Betrieb?“ ist eine Ja/Nein-Frage. Das ist völlig in Ordnung: Boolesche Werte eignen sich gut als abgeleitete Ausgaben, aber weniger als Quelle der Wahrheit.

    readonly isBusy = computed(() =>
      [this.orders(), this.checkout()].some(isLoading),
    );
    
    readonly canCheckout = computed(
      () => this.orders().status === 'success' && this.checkout().status !== 'loading',
    );
    

    Weil diese Werte aus den zugrundeliegenden Zuständen berechnet werden, können sie nicht abweichen. Fügt man eine Operation hinzu oder entfernt sie, passen sich die abgeleiteten Flags automatisch an; es gibt nichts zu resetten und nichts zu vergessen.

    Schritt 4: Übergänge zentralisieren in einem RxJS-Operator

    Die manuelle Setzung von isLoading = true vor einer Anfrage sowie von false in zwei Callbacks ist die Ursache für die meisten Fehler mit Flaggen. Ein benutzerdefinierter Operator kann hingegen alle Übergänge steuern:

    import { OperatorFunction, of } from 'rxjs';
    import { catchError, map, startWith } from 'rxjs/operators';
    
    export function toAsyncState<T, E = HttpErrorResponse>(): OperatorFunction<T, AsyncState<T, E>> {
      return (source$) =>
        source$.pipe(
          map((data) => success(data)),
          catchError((error: E) => of(failure(error))),
          startWith(loading()),
        );
    }
    

    Der Code besteht aus drei Operatoren, wobei jeder eine Garantie bietet:

    • map umhüllt jeden ausgegebenen Wert in einen success-Zustand.
  • catchError wandelt einen Fehler in einen failure-Wert um, sodass der Stream selbst niemals Fehler aufweist. Dadurch bleiben Verbraucher wie toSignal, rxMethod sowie der async-Pipeline auch nach einem fehlgeschlagenen Antrag aktiv.
  • startWith gibt beim Abonnieren synchron einen loading-Status aus, wodurch die Benutzeroberfläche vor Beginn des Anfrages niemals einen idle-Zustand anzeigt.
  • Da keine Flags mehr vorhanden sind, gibt es nichts, was man vergessen könnte zurückzusetzen.

    Für Daten, die beim Erstellen der Komponente abgerufen werden, kombinieren sich die einzelnen Bestandteile zu einer einzigen Deklaration:

    readonly orders = toSignal(
      this.api.getOrders().pipe(toAsyncState()),
      { requireSync: true },
    );
    

    Die Verwendung von requireSync: true ist hier speziell aufgrund von startWith sicher. Das Observable gibt beim Abonnieren synchron Werte aus, sodass das Signal den Wert aus der ersten Änderungsüberprüfung beibehält – es ist daher kein initialValue erforderlich. Entfernt man hingegen startWith, würde toSignal stattdessen einen Fehler werfen.

    Schritt 5: vom Benutzer ausgelöste Befehle in einem Signal-Store

    Das Laden bei der Initialisierung ist der einfache Fall. Befehle wie Checkout, Cancel oder Retry werden vom Benutzer ausgelöst und erfordern Konkurrenzregeln. Mit NgRx Signal Store werden die Operatoren direkt in rxMethod eingefügt; dieselbe Struktur lässt sich auch auf einen herkömmlichen Service übertragen, wenn man NgRx nicht verwendet:

    interface OrdersState {
      orders: AsyncState<Order[]>;
      checkout: AsyncState<Receipt>;
    }
    
    const initialState: OrdersState = { orders: idle(), checkout: idle() };
    
    export const OrdersStore = signalStore(
      withState(initialState),
      withComputed(({ orders, checkout }) => ({
        isBusy: computed(() => [orders(), checkout()].some(isLoading)),
        canCheckout: computed(
          () => orders().status === 'success' && checkout().status !== 'loading',
        ),
      })),
      withMethods((store, api = inject(OrdersApi)) => ({
        loadOrders: rxMethod<void>(
          pipe(
            switchMap(() => api.getOrders().pipe(toAsyncState())),
            tap((orders) => patchState(store, { orders })),
          ),
        ),
        checkout: rxMethod<Order[]>(
          pipe(
            exhaustMap((orders) => api.checkout(orders).pipe(toAsyncState())),
            tap((checkout) => patchState(store, { checkout })),
          ),
        ),
      })),
      withHooks({ onInit: (store) => store.loadOrders() }),
    );
    

    Es gibt einige Punkte, die man beachten sollte. Der Store-Zustand enthält einen AsyncState pro Operation. withComputed definiert die abgeleiteten Flags an einem Ort. loadOrders verwendet switchMap, sodass ein neuer Ladevorgang einen veralteten aufhebt. checkout hingegen nutzt exhaustMap, der neue Auslösungen ignoriert, solange ein Checkout bereits läuft.

    Diese letzte Wahl ersetzt die Schutzbedingung, die die boolesche Version benötigte – etwa if (this.isCheckingOut) return; – um doppelte Einreichungen zu verhindern. Solche Schutzbedingungen verschwinden oft bei Refaktorisierungen, weil ihr Zweck nicht offensichtlich ist. Hier befindet sich die Konkurrenzregel direkt innerhalb des Pipelines neben der Anfrage, die sie schützt.

    Schritt 6: Ein Template, das die Zustandsmaschine widerspiegelt

    Mit nur einem Status, auf den umgeschaltet werden kann, listet das Template jeden visuellen Zustand genau einmal auf:

    @let orders = store.orders();
    
    @switch (orders.status) {
      @case ('loading') {
        <app-spinner />
      }
      @case ('error') {
        <app-error [error]="orders.error" (retry)="store.loadOrders()" />
      }
      @case ('success') {
        @if (orders.data.length) {
          <app-order-list [orders]="orders.data" />
        } @else {
          <app-empty />
        }
      }
    }
    
    <button [disabled]="!store.canCheckout()" (click)="store.checkout(orders.data)">
      Checkout
    </button>
    

    Verglichen mit den vier übereinanderliegenden @if-Blöcken am Anfang gibt es keine weiteren Kombinationen mehr, über die man nachdenken müsste. Im Fall ‚success‘ ist orders.data verfügbar und hat die richtige Typisierung; im Fall ‚loading‘ existiert es nicht, und der Typüberprüfer des Templates lehnt jeden Versuch ab, es zu verwenden.

    Die @let-Deklaration erledigt wichtige Arbeit. TypeScript kann store.orders().status bei getrennten Aufrufen nicht einengen, da der Compiler davon ausgeht, dass jeder Aufruf von store.orders() ein anderes Objekt zurückgeben könnte. Durch das Einlesen des Signals einmal in eine lokale Variable erhält man einen stabilen Referenzpunkt, um den Typ einzugrenzen – genauso wie mit einem const in herkömmlichem TypeScript. Ohne diese Methode müsste man auf Typumwandlungen oder Hilfsgetter zurückgreifen.

    Es gibt eine Einschränkung bezüglich des Bezahlfeld-Buttons in diesem Beispiel: Er befindet sich außerhalb von @switch, wodurch orders.data dort nicht eingegrenzt wird, und eine strenge Typüberprüfung der Vorlage weist dies aus. Entweder wird der Button in den 'success'-Zweig verschoben oder dem Store wird eine Bezahlmethode gegeben, die die aktuellen Bestellungen selbst abruft.

    Bestehende Daten bei einem Neuladen beibehalten

    Jedes Mal, wenn der Benutzer neu lädt, wird der Bildschirm leergefegt – das ist eine schlechte Benutzererfahrung. Mit Flags ist es überraschend schwierig, alte Daten sichtbar zu halten: Eine Bedingung wie isLoading && orders.length > 0 funktioniert, solange niemand die Liste orders vor der Anfrage leert. Mit einer Union handelt es sich dabei einfach um eine weitere Variante, die die vorherigen Daten übernimmt:

    export type AsyncState<T, E = HttpErrorResponse> =
      | { readonly status: 'idle' }
      | { readonly status: 'loading' }
      | { readonly status: 'reloading'; readonly data: T }
      | { readonly status: 'success'; readonly data: T }
      | { readonly status: 'error'; readonly error: E };
    

    Der Operator kann den vorherigen Zustand akzeptieren und entsprechend seine Ausgangsvariante wählen:

    export function toAsyncState<T, E = HttpErrorResponse>(
      previous?: AsyncState<T, E>,
    ): OperatorFunction<T, AsyncState<T, E>> {
      const initial =
        previous?.status === 'success' ? reloading(previous.data) : loading();
    
      return (source$) =>
        source$.pipe(
          map((data) => success(data)),
          catchError((error: E) => of(failure(error))),
          startWith(initial),
        );
    }
    

    Falls der vorherige Zustand erfolgreich war, beginnt der Stream mit reloading und behält die alten Daten bei; andernfalls beginnt er mit loading. Beachten Sie, dass der Codeausschnitt von einem Konstruktor reloading() neben den anderen ausgeht, der genauso definiert ist wie success(). Im Template kann ein Zweig @case ('reloading') die vorhandene Liste zusammen mit einer schmalen Fortschrittsleiste anzeigen und den Vollbild-Spinner für die erste Ladeung reservieren. Der Zustand „Wir haben Daten und holen weitere“ lässt sich leicht als Variante darstellen, führt aber aufgrund von zwei Booleschen Werten häufig zu Fehlern.

    Was ist mit einem globalen Lade-Service?

    Viele Apps zählen die Anfragen während des Transports in einem HTTP-Interceptor, um einen globalen Fortschrittsbalken anzuzeigen. Das ist eine sinnvolle Verwendung. Als Grundlage für die Benutzeroberfläche von Komponenten ist es jedoch unzureichend: Ein Zähler kann nicht angeben, welche Anfrage noch aussteht, auch Hintergrundabfragen sowie Analysetraffic lösen ihn aus, und jedes Bildschirm zeigt letztendlich denselben Indikator.

    Falls Sie beides benötigen, leiten Sie den globalen Indikator aus dem lokalen Zustand ab – beispielsweise über einen computed-Wert, der aus dem isBusy-Status jedes Stores berechnet wird – anstatt umgekehrt. Der lokale Zustand sollte niemals von einem Zähler abhängen, den er nicht selbst kontrolliert. Für die spezielle Handhabung von Fehlern behandelt unser Artikel zu der Gestaltung von Lösungen für fehlgeschlagene Anfragen in Angular Interceptors sowie Strategien zur Wiederholung der Anfragen.

    Wo resource() und httpResource() zum Einsatz kommen

    Die resource()- und httpResource()-APIs von Angular stellen ein status()-Signal bereit, dessen Werte wie 'loading', 'reloading', 'resolved' und 'error' sind. Das Framework hat sich für dieselbe Konzeption entschieden: einen eindeutigen Status anstelle einer Sammlung von Flags. Diese APIs haben sich in den neueren Angular-Versionen weiterentwickelt, daher sollten Sie die aktuelle Dokumentation prüfen, um die genauen Statuswerte für Ihre Version zu erfahren.

    Falls Ihre Daten durch Signale wie eine ID, ein Filter oder eine Seitenzahl abgerufen werden, wenden Sie sich zunächst an resource(). Die AsyncState-Union umfasst Fälle, die nicht abgedeckt werden: Befehle wie POST und DELETE, Codebasen, die noch größtenteils auf RxJS basieren, sowie domänenspezifische Zustände, die das Framework nicht modelliert. Das Denkmuster bleibt in beiden Fällen identisch: ein einziger Status, keine vielen Flags.

    Wann ein Boolean immer noch das richtige Werkzeug ist

    Ein Bildschirm mit einer einzigen Abfrage, einem einzigen Ladeindikator und ohne Fehlerbehandlung funktioniert mit isLoading. Dies in eine Union einzubetten wäre nur Formalität.

    Eine praktische Regel: das zweite Boolean ist das Signal zum Refactoring. Sobald Sie vorhaben, isSaving neben isLoading hinzuzufügen, wechseln Sie auf die Union. Das kostet eine Typdatei und einen Operator.

    Kernpunkte

    • Laden, Erfolg und Fehler sind gegenseitig ausschließend, daher sollten sie als eine diskriminierte Union statt als unabhängige Flags modelliert werden.
    • Verwenden Sie einen AsyncState pro asynchrone Operation, nicht ein Flag pro Fehlermodus.
    • Betrachten Sie Booleans als aus computed erzeugte abgeleitete Lesemodelle, niemals als gespeicherten Zustand.
  • Ein einziger toAsyncState()-Operator steuert alle Übergänge und hält die Ströme am Laufen, indem er Fehler in Werte umwandelt.
  • Drücken Sie Konkurrenzregeln mit den Flattening-Operatoren (switchMap, exhaustMap) aus, anstatt manuelle Schutzmechanismen zu verwenden.
  • Kombinieren Sie @let mit @switch, damit das Template der Zustandsmaschine folgt und der Typprüfer dies durchsetzt.
  • Die Komponente am Anfang dieses Leitfadens enthielt sieben Buchhaltungsfelder; die überarbeitete Version hat nur zwei, und beide repräsentieren echten Zustand. Der Unterschied ist nicht stilistischer Natur. Eine Datentypklasse kann 32 Zustände beschreiben, die andere genau die fünf, für die die Benutzeroberfläche konzipiert wurde.