Skip to content

Muster für bedingte Verzweigungen in subscribe

Eines der häufigsten Probleme, in das Entwickler, die mit RxJS beginnen, geraten, ist die Durchführung komplexer bedingter Verzweigungen innerhalb des subscribe()-Callbacks. Dieser Artikel erklärt, warum dies problematisch ist und wie es verbessert werden sollte, zusammen mit praktischen Mustern.

In diesem Artikel verwendete öffentliche API

Dieser Artikel verwendet JSONPlaceholder, eine kostenlose REST-API. Da es sich um funktionierenden Code handelt, können Sie ihn kopieren und in Ihrem Browser ausprobieren.

Es werden auch Konvertierungsmuster zu benutzerdefinierten API-Antworttypen (einschließlich status, errorCode usw.) gezeigt, die als Referenz beim Konvertieren von Antworten externer APIs in interne Typen in tatsächlichen Projekten dienen können.

Problem: Komplexe bedingte Verzweigungen in subscribe

Bedingte Verzweigungen innerhalb von subscribe verringern die Lesbarkeit des Codes und erschweren auch das Testen. Schauen wir uns das folgende Beispiel an.

Vorbereitung: Hilfsfunktion zum Wrappen der JSONPlaceholder-API

Definieren wir zunächst eine Hilfsfunktion, die die Antwort der JSONPlaceholder-API in einen benutzerdefinierten Typ konvertiert. Dieses Muster kann auch beim Konvertieren externer APIs in interne Typen in tatsächlichen Projekten verwendet werden.

typescript
import { Observable, from, of, map, catchError } from 'rxjs';

/**
 * JSONPlaceholder API Antworttyp
 */
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

/**
 * Benutzerdefinierter API-Antworttyp
 */
interface ApiResponse<T = any> {
  status: 'success' | 'error' | 'pending';
  data?: T;
  errorCode?: string;
  message?: string;
}

/**
 * Wrappen der JSONPlaceholder-API und Konvertierung in benutzerdefinierten API-Antworttyp
 *
 * Auch in tatsächlichen Projekten wird häufig das Muster verwendet, Antworten externer APIs in interne Typen zu konvertieren.
 * Dadurch können die Auswirkungen von API-Änderungen lokalisiert und konsistente Typen innerhalb der Anwendung verwendet werden.
 */
function fetchPost(postId: number = 1): Observable<ApiResponse<Post>> {
  return from(
    fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json();
      })
  ).pipe(
    map((data: Post) => ({
      status: 'success' as const,
      data
    })),
    catchError(err => {
      // Fehler in benutzerdefinierten Antworttyp konvertieren
      let errorCode = 'UNKNOWN_ERROR';

      if (err.message.includes('404')) {
        errorCode = 'NOT_FOUND';
      } else if (err.message.includes('500')) {
        errorCode = 'SERVER_ERROR';
      } else if (err.message.includes('Failed to fetch')) {
        errorCode = 'NETWORK_ERROR';
      }

      return of({
        status: 'error' as const,
        errorCode,
        message: err.message
      });
    })
  );
}

Anti-Pattern-Beispiel

Im folgenden Beispiel wird die obige Hilfsfunktion (fetchPost) verwendet, aber innerhalb von subscribe() werden komplexe bedingte Verzweigungen durchgeführt.

typescript
/**
 * ❌ Problematisches Codebeispiel
 * Komplexe bedingte Verzweigungen innerhalb von subscribe
 */
class BadExampleManager {
  loading = false;
  data: Post | null = null;
  errorMessage = '';
  successMessage = '';

  // Callback für UI-Update (optional)
  onStateChange?: (state: { loading: boolean; data: Post | null; errorMessage: string; successMessage: string }) => void;

  init(): void {
    this.fetchData();
  }

  fetchData(): void {
    this.loading = true;
    this.updateUI();

    // Post-Daten von JSONPlaceholder API abrufen
    fetchPost(1).subscribe({
      next: response => {
        this.loading = false;

        // ❌ Problem: Komplexe bedingte Verzweigung innerhalb von subscribe
        if (response.status === 'success') {
          if (response.data) {
            // Datenvalidierung
            if (response.data.title && response.data.body) {
              this.data = response.data;
              this.successMessage = `Post „${response.data.title}" geladen`;
            } else {
              this.errorMessage = 'Datenformat ist ungültig';
            }
          } else {
            this.errorMessage = 'Keine Daten vorhanden';
          }
        } else if (response.status === 'error') {
          if (response.errorCode === 'NOT_FOUND') {
            this.errorMessage = 'Daten nicht gefunden (nicht existierende Post-ID)';
          } else if (response.errorCode === 'NETWORK_ERROR') {
            this.errorMessage = 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.';
            // Wiederholungsversuch
            setTimeout(() => this.fetchData(), 5000);
          } else if (response.errorCode === 'SERVER_ERROR') {
            this.errorMessage = 'Serverfehler aufgetreten';
          } else {
            this.errorMessage = 'Ein Fehler ist aufgetreten';
          }
        } else if (response.status === 'pending') {
          this.errorMessage = 'Wird verarbeitet. Bitte warten Sie einen Moment.';
          // Polling
          setTimeout(() => this.fetchData(), 3000);
        }
        this.updateUI();
      }
    });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        loading: this.loading,
        data: this.data,
        errorMessage: this.errorMessage,
        successMessage: this.successMessage
      });
    }
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const badExample = new BadExampleManager();
badExample.onStateChange = (state) => {
  if (state.loading) console.log('Lädt...');
  if (state.errorMessage) console.error(state.errorMessage);
  if (state.successMessage) console.log(state.successMessage);
  if (state.data) console.log('Daten:', state.data);
};
badExample.init();

// Ausgabebeispiel:
// Lädt...
// Post „sunt aut facere repellat provident occaecati excepturi optio reprehenderit" geladen
// Daten: {userId: 1, id: 1, title: "...", body: "..."}

Probleme dieser Implementierung

Probleme durch bedingte Verzweigungen in subscribe

  1. Verringerte Lesbarkeit - Tiefe Verschachtelung, schwer zu verfolgen
  2. Schwierige Tests - Logik innerhalb von subscribe ist schwer zu testen
  3. Fehlende Wiederverwendbarkeit - Dieselbe Logik kann nicht an anderer Stelle verwendet werden
  4. Nicht deklarativ - Weicht vom deklarativen Stil von RxJS ab
  5. Verwaltung von Seiteneffekten - Mehrere Zustandsänderungen sind verstreut und schwer nachzuverfolgen

Überblick über Lösungen

Bedingte Verzweigungen in subscribe werden mithilfe von RxJS-Operatoren innerhalb der Pipeline verarbeitet. Dadurch wird der Code deklarativ und das Testen wird einfacher.

In diesem Artikel werden die folgenden 4 Muster erklärt:

  1. Muster 1: Verzweigung mit filter + tap
  2. Muster 2: Verzweigung mit partition
  3. Muster 3: Dynamische Verzweigung mit switchMap + iif
  4. Muster 4: Funktionskapselung + map-Transformation (empfohlen)

Muster 1: Verzweigung mit filter + tap

Die einfachste Methode besteht darin, mit filter nur Werte durchzulassen, die die Bedingung erfüllen, und mit tap Seiteneffekte auszuführen.

Implementierungsbeispiel

In diesem Beispiel wird die oben definierte Funktion fetchPost() verwendet, um Daten von der JSONPlaceholder-API abzurufen.

typescript
class Pattern1Manager {
  private destroy$ = new Subject<void>();

  loading = false;
  data: Post | null = null;
  errorMessage = '';

  // Callback für UI-Update (optional)
  onStateChange?: (state: { loading: boolean; data: Post | null; errorMessage: string }) => void;

  init(): void {
    this.fetchData();
  }

  fetchData(): void {
    this.loading = true;
    this.updateUI();

    fetchPost(1).pipe(
      // ✅ Nur erfolgreiche Antworten durchlassen
      filter(response => response.status === 'success'),

      // ✅ Datenvalidierung
      filter(response => response.data !== undefined),

      // ✅ Protokollausgabe (Seiteneffekt)
      tap(response => console.log('Daten erfolgreich abgerufen:', response.data)),

      // Fehlerbehandlung
      catchError(err => {
        console.error('Fehler:', err);
        this.errorMessage = 'Datenabruf fehlgeschlagen';
        this.loading = false;
        this.updateUI();
        return of(null);
      }),

      takeUntil(this.destroy$)
    ).subscribe({
      next: response => {
        this.loading = false;

        // ✅ Nur einfache Zuweisung
        if (response) {
          this.data = response.data;
        }
        this.updateUI();
      }
    });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        loading: this.loading,
        data: this.data,
        errorMessage: this.errorMessage
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const pattern1 = new Pattern1Manager();
pattern1.onStateChange = (state) => {
  if (state.loading) console.log('Lädt...');
  if (state.errorMessage) console.error(state.errorMessage);
  if (state.data) console.log('Post-Daten:', state.data);
};
pattern1.init();

// Ausgabebeispiel:
// Lädt...
// Daten erfolgreich abgerufen: {userId: 1, id: 1, title: "...", body: "..."}
// Post-Daten: {userId: 1, id: 1, title: "...", body: "..."}

Vor- und Nachteile

Merkmale von Muster 1

Vorteile:

  • Einfach und leicht verständlich
  • Stufenweises Filtern möglich
  • Jeder Schritt ist klar

Nachteile:

  • Nicht geeignet für mehrere Verzweigungen (Stream muss für jede Verzweigung erstellt werden)
  • Gefilterte Werte können nicht weiter verwendet werden

Muster 2: Verzweigung mit partition

Mit partition können Sie ein Observable basierend auf einer Bedingung in zwei Streams aufteilen.

Implementierungsbeispiel

Auch in diesem Beispiel wird die Funktion fetchPost() verwendet, um Daten von der JSONPlaceholder-API abzurufen.

typescript
/**
 * Typ für Verarbeitungsergebnis
 */
interface ProcessedResult {
  type: 'success' | 'error';
  data?: Post;
  message: string;
}

class Pattern2Manager {
  private destroy$ = new Subject<void>();

  loading = false;
  result: ProcessedResult | null = null;

  // Callback für UI-Update (optional)
  onStateChange?: (state: { loading: boolean; result: ProcessedResult | null }) => void;

  init(): void {
    this.fetchData();
  }

  fetchData(): void {
    this.loading = true;
    this.updateUI();

    const response$ = fetchPost(1).pipe(
      catchError(err => {
        console.error('HTTP-Fehler:', err);
        return EMPTY;
      })
    );

    // ✅ Aufteilung in Erfolg und Fehler
    const [success$, error$] = partition(
      response$,
      response => response.status === 'success'
    );

    // Verarbeitung des Erfolgs-Streams
    const successStream$ = success$.pipe(
      map(response => ({
        type: 'success' as const,
        data: response.data,
        message: 'Daten abgerufen'
      })),
      tap(() => console.log('Erfolgsverarbeitung abgeschlossen'))
    );

    // Verarbeitung des Fehler-Streams
    const errorStream$ = error$.pipe(
      map(response => ({
        type: 'error' as const,
        message: this.getErrorMessage(response.errorCode)
      })),
      tap(result => console.log('Fehlerverarbeitung abgeschlossen:', result.message))
    );

    // ✅ Beide Streams zusammenführen und abonnieren
    merge(successStream$, errorStream$).pipe(
      takeUntil(this.destroy$)
    ).subscribe({
      next: result => {
        this.loading = false;
        this.result = result;

        // Zusätzliche Verarbeitung basierend auf bestimmtem Fehlercode
        if (result.type === 'error') {
          this.handleError(result);
        }
        this.updateUI();
      }
    });
  }

  private getErrorMessage(errorCode?: string): string {
    const messages: Record<string, string> = {
      'NOT_FOUND': 'Daten nicht gefunden (nicht existierende Post-ID)',
      'NETWORK_ERROR': 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
      'SERVER_ERROR': 'Serverfehler aufgetreten'
    };

    return messages[errorCode || ''] || 'Ein Fehler ist aufgetreten';
  }

  private handleError(result: ProcessedResult): void {
    // Zusätzliche Verarbeitung basierend auf Fehlertyp (z. B. Weiterleitung zum Login-Bildschirm)
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        loading: this.loading,
        result: this.result
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const pattern2 = new Pattern2Manager();
pattern2.onStateChange = (state) => {
  if (state.loading) console.log('Lädt...');
  if (state.result) {
    console.log(`[${state.result.type}] ${state.result.message}`);
    if (state.result.data) console.log('Post-Daten:', state.result.data);
  }
};
pattern2.init();

// Ausgabebeispiel:
// Lädt...
// Erfolgsverarbeitung abgeschlossen
// [success] Daten abgerufen
// Post-Daten: {userId: 1, id: 1, title: "...", body: "..."}

Vor- und Nachteile

Merkmale von Muster 2

Vorteile:

  • Klare Trennung von zwei Verzweigungen
  • Unabhängige Verarbeitung in jedem Stream möglich
  • Leicht zu testen

Nachteile:

  • Begrenzt auf zwei Verzweigungen (nicht geeignet für drei oder mehr Verzweigungen)
  • Beide Streams müssen abonniert werden

Muster 3: Dynamische Verzweigung mit switchMap + iif

Mit iif können Sie basierend auf Bedingungen zu verschiedenen Observables wechseln.

Implementierungsbeispiel

In diesem Beispiel wird eine nicht existierende Post-ID (999) ausprobiert, um die Fehlerverarbeitung zu demonstrieren.

typescript
class Pattern3Manager {
  private destroy$ = new Subject<void>();

  loading = false;
  data: Post | null = null;
  statusMessage = '';

  // Callback für UI-Update (optional)
  onStateChange?: (state: { loading: boolean; data: Post | null; statusMessage: string }) => void;

  init(): void {
    // Nicht existierende Post-ID (999) ausprobieren
    this.fetchData(999);
  }

  fetchData(postId: number = 1): void {
    this.loading = true;
    this.updateUI();

    fetchPost(postId).pipe(
      // ✅ Verarbeitung basierend auf Antwort-Status verzweigen
      switchMap(response =>
        iif(
          // Bedingung 1: Erfolg
          () => response.status === 'success',
          // Verarbeitung bei Erfolg
          of(response).pipe(
            map(r => ({ type: 'success' as const, data: r.data })),
            tap(() => console.log('Daten erfolgreich abgerufen'))
          ),
          // Bedingung 2: Fehler
          // Bei Fehler in Error-Stream mit throwError konvertieren
          throwError(() => ({
            message: this.getErrorMessage(response.errorCode),
            errorCode: response.errorCode
          }))
        )
      ),
      retry({
        count: 2,
        delay: 1000 // 1 Sekunde warten und wiederholen
      }),
      catchError(err => {
        console.error('Fehler:', err);
        this.statusMessage = err.message || 'Ein Fehler ist aufgetreten';
        this.loading = false;
        this.updateUI();
        return EMPTY;
      }),
      takeUntil(this.destroy$)
    ).subscribe({
      next: result => {
        this.loading = false;

        if (result.type === 'success') {
          this.data = result.data;
          this.statusMessage = '';
        }
        this.updateUI();
      }
    });
  }

  private getErrorMessage(errorCode?: string): string {
    const messages: Record<string, string> = {
      'NOT_FOUND': 'Daten nicht gefunden (nicht existierende Post-ID)',
      'NETWORK_ERROR': 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
      'SERVER_ERROR': 'Serverfehler aufgetreten'
    };
    return messages[errorCode || ''] || 'Ein Fehler ist aufgetreten';
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange({
        loading: this.loading,
        data: this.data,
        statusMessage: this.statusMessage
      });
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const pattern3 = new Pattern3Manager();
pattern3.onStateChange = (state) => {
  if (state.loading) console.log('Lädt...');
  if (state.statusMessage) console.log(state.statusMessage);
  if (state.data) console.log('Post-Daten:', state.data);
};
pattern3.init();

// Ausgabebeispiel (bei Versuch mit nicht existierender Post-ID 999):
// Lädt...
// Fehler: {...}
// Daten nicht gefunden (nicht existierende Post-ID)
//
// Bei Angabe einer normalen Post-ID (1):
// pattern3.fetchData(1);
// Lädt...
// Daten erfolgreich abgerufen
// Post-Daten: {userId: 1, id: 1, title: "...", body: "..."}

Vor- und Nachteile

Merkmale von Muster 3

Vorteile:

  • Wechsel zu verschiedenen Observables basierend auf Bedingungen möglich
  • Leicht mit Wiederholungslogik kombinierbar
  • Geeignet für dynamische Verarbeitung (Polling, rekursive Aufrufe usw.)

Nachteile:

  • Lesbarkeit kann bei tiefer Verschachtelung abnehmen
  • Schwierig zu debuggen
  • Für Anfänger schwer verständlich

Hinweis: Dieses Beispiel zeigt eine einfache Erfolg/Fehler-Verzweigung, kann aber tatsächlich auch komplexere dynamische Verarbeitung wie asynchrones Polling mit Pending-Status unterstützen.

Muster 4: Funktionskapselung + map-Transformation (empfohlen)

Das am meisten empfohlene Muster ist, die Logik für bedingte Verzweigungen als reine Funktion zu extrahieren und mit dem map-Operator zu transformieren.

Implementierungsbeispiel

In diesem Beispiel wird eine reine Funktion definiert, die die von fetchPost() zurückgegebene API-Antwort in ein ViewModel für die UI-Anzeige transformiert.

typescript
/**
 * ViewModel für UI-Anzeige
 */
interface ViewModel {
  loading: boolean;
  displayData: Post | null;
  messageType: 'success' | 'error' | 'info' | null;
  message: string;
  showRetryButton: boolean;
}

/**
 * ✅ Reine Funktion: API-Antwort in ViewModel konvertieren
 * Leicht testbar und wiederverwendbar
 */
function mapResponseToViewModel(response: ApiResponse<Post>): ViewModel {
  // Verarbeitung erfolgreicher Antwort
  if (response.status === 'success') {
    return {
      loading: false,
      displayData: response.data || null,
      messageType: 'success',
      message: `Post „${response.data?.title}" geladen`,
      showRetryButton: false
    };
  }

  // Verarbeitung von Fehlerantwort
  if (response.status === 'error') {
    const errorMessages: Record<string, string> = {
      'NOT_FOUND': 'Daten nicht gefunden (nicht existierende Post-ID)',
      'NETWORK_ERROR': 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
      'SERVER_ERROR': 'Serverfehler aufgetreten. Bitte versuchen Sie es später erneut.'
    };

    const message = errorMessages[response.errorCode || ''] ||
                    response.message ||
                    'Ein Fehler ist aufgetreten';

    return {
      loading: false,
      displayData: null,
      messageType: 'error',
      message,
      showRetryButton: isRetryableError(response.errorCode)
    };
  }

  // Verarbeitung von Pending-Antwort (tritt bei JSONPlaceholder API nicht auf, aber für zukünftige Erweiterung)
  if (response.status === 'pending') {
    return {
      loading: true,
      displayData: null,
      messageType: 'info',
      message: 'Wird verarbeitet. Bitte warten Sie einen Moment.',
      showRetryButton: false
    };
  }

  // Fallback
  return {
    loading: false,
    displayData: null,
    messageType: 'error',
    message: 'Unbekannte Antwort',
    showRetryButton: false
  };
}

/**
 * ✅ Reine Funktion: Bestimmt, ob Fehler wiederholbar ist
 */
function isRetryableError(errorCode?: string): boolean {
  const retryableErrors = ['NETWORK_ERROR', 'SERVER_ERROR'];
  return retryableErrors.includes(errorCode || '');
}

/**
 * ✅ Reine Funktion: Gibt Seiteneffekt basierend auf bestimmtem Fehler zurück
 */
function getErrorSideEffect(errorCode?: string): (() => void) | null {
  const sideEffects: Record<string, () => void> = {
    'NETWORK_ERROR': () => {
      console.log('Netzwerkfehler: Bitte überprüfen Sie Ihre Verbindung');
    },
    'SERVER_ERROR': () => {
      console.log('Serverfehler: Bitte warten Sie einen Moment und versuchen Sie es erneut');
    }
  };

  return sideEffects[errorCode || ''] || null;
}

class Pattern4Manager {
  private destroy$ = new Subject<void>();

  viewModel: ViewModel = {
    loading: false,
    displayData: null,
    messageType: null,
    message: '',
    showRetryButton: false
  };

  // Callback für UI-Update (optional)
  onStateChange?: (viewModel: ViewModel) => void;

  init(): void {
    this.fetchData();
  }

  fetchData(postId: number = 1): void {
    // Anfänglichen Ladestatus setzen
    this.viewModel = {
      loading: true,
      displayData: null,
      messageType: null,
      message: '',
      showRetryButton: false
    };
    this.updateUI();

    fetchPost(postId).pipe(
      // ✅ Transformation mit reiner Funktion innerhalb der Pipeline
      map(response => mapResponseToViewModel(response)),

      // ✅ Seiteneffekte mit tap ausführen (von reiner Funktion getrennt)
      tap(viewModel => {
        console.log('ViewModel:', viewModel);
      }),

      // Fehlerbehandlung
      catchError(err => {
        console.error('HTTP-Fehler:', err);

        const errorViewModel: ViewModel = {
          loading: false,
          displayData: null,
          messageType: 'error',
          message: 'Netzwerkfehler aufgetreten',
          showRetryButton: true
        };

        return of(errorViewModel);
      }),

      takeUntil(this.destroy$)
    ).subscribe({
      next: viewModel => {
        // ✅ Nur einfache Zuweisung innerhalb von subscribe
        this.viewModel = viewModel;

        // Seiteneffekt basierend auf bestimmtem Fehler ausführen
        if (viewModel.messageType === 'error') {
          const sideEffect = getErrorSideEffect(
            // Hier wird der ursprüngliche errorCode nicht beibehalten,
            // daher muss er zum ViewModel hinzugefügt werden
          );
          sideEffect?.();
        }
        this.updateUI();
      }
    });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange(this.viewModel);
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const pattern4 = new Pattern4Manager();
pattern4.onStateChange = (viewModel) => {
  if (viewModel.loading) console.log('Lädt...');
  if (viewModel.message) console.log(`[${viewModel.messageType}] ${viewModel.message}`);
  if (viewModel.displayData) console.log('Post-Daten:', viewModel.displayData);
  if (viewModel.showRetryButton) console.log('Wiederholungsschaltfläche anzeigen');
};
pattern4.init();

// Ausgabebeispiel:
// Lädt...
// ViewModel: {loading: false, displayData: {...}, messageType: "success", message: "Post „..." geladen", showRetryButton: false}
// [success] Post „sunt aut facere repellat provident occaecati excepturi optio reprehenderit" geladen
// Post-Daten: {userId: 1, id: 1, title: "...", body: "..."}

Weitere Verbesserung: errorCode beibehalten

Im obigen Beispiel ist errorCode nicht im ViewModel enthalten, daher kann die Beurteilung von Seiteneffekten nicht durchgeführt werden. Hier ist eine verbesserte Version.

typescript
/**
 * Verbessertes ViewModel (mit errorCode)
 */
interface ImprovedViewModel {
  loading: boolean;
  displayData: Post | null;
  messageType: 'success' | 'error' | 'info' | null;
  message: string;
  showRetryButton: boolean;
  errorCode?: string; // ✅ Fehlercode beibehalten
}

/**
 * Verbesserte Mapping-Funktion
 */
function mapResponseToImprovedViewModel(response: ApiResponse<Post>): ImprovedViewModel {
  if (response.status === 'success') {
    return {
      loading: false,
      displayData: response.data || null,
      messageType: 'success',
      message: `Post „${response.data?.title}" geladen`,
      showRetryButton: false
    };
  }

  if (response.status === 'error') {
    const errorMessages: Record<string, string> = {
      'NOT_FOUND': 'Daten nicht gefunden (nicht existierende Post-ID)',
      'NETWORK_ERROR': 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
      'SERVER_ERROR': 'Serverfehler aufgetreten'
    };

    return {
      loading: false,
      displayData: null,
      messageType: 'error',
      message: errorMessages[response.errorCode || ''] || 'Ein Fehler ist aufgetreten',
      showRetryButton: isRetryableError(response.errorCode),
      errorCode: response.errorCode // ✅ Fehlercode beibehalten
    };
  }

  if (response.status === 'pending') {
    return {
      loading: true,
      displayData: null,
      messageType: 'info',
      message: 'Wird verarbeitet',
      showRetryButton: false
    };
  }

  return {
    loading: false,
    displayData: null,
    messageType: 'error',
    message: 'Unbekannte Antwort',
    showRetryButton: false
  };
}

class ImprovedPattern4Manager {
  private destroy$ = new Subject<void>();
  viewModel: ImprovedViewModel = {
    loading: false,
    displayData: null,
    messageType: null,
    message: '',
    showRetryButton: false
  };

  // Callback für UI-Update (optional)
  onStateChange?: (viewModel: ImprovedViewModel) => void;

  init(): void {
    this.fetchData();
  }

  fetchData(postId: number = 1): void {
    fetchPost(postId).pipe(
      map(response => mapResponseToImprovedViewModel(response)),
      tap(viewModel => console.log('ViewModel:', viewModel)),
      catchError(err => {
        console.error('HTTP-Fehler:', err);
        return of({
          loading: false,
          displayData: null,
          messageType: 'error' as const,
          message: 'Netzwerkfehler',
          showRetryButton: true
        });
      }),
      takeUntil(this.destroy$)
    ).subscribe({
      next: viewModel => {
        this.viewModel = viewModel;

        // ✅ Seiteneffekt basierend auf Fehlercode ausführen
        if (viewModel.errorCode) {
          const sideEffect = getErrorSideEffect(viewModel.errorCode);
          sideEffect?.();
        }
        this.updateUI();
      }
    });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange(this.viewModel);
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const improvedPattern4 = new ImprovedPattern4Manager();
improvedPattern4.onStateChange = (viewModel) => {
  if (viewModel.loading) console.log('Lädt...');
  if (viewModel.message) console.log(`[${viewModel.messageType}] ${viewModel.message}`);
  if (viewModel.displayData) console.log('Post-Daten:', viewModel.displayData);
};
improvedPattern4.init();

// Ausgabebeispiel:
// ViewModel: {loading: false, displayData: {...}, messageType: "success", message: "...", showRetryButton: false}
// [success] Post „sunt aut facere repellat provident occaecati excepturi optio reprehenderit" geladen
// Post-Daten: {userId: 1, id: 1, title: "...", body: "..."}

Vor- und Nachteile

Merkmale von Muster 4 (empfohlen)

Vorteile:

  • Leicht testbar: Einfache Unit-Tests für reine Funktionen
  • Wiederverwendbar: Dieselbe Logik kann in anderen Komponenten verwendet werden
  • Hohe Lesbarkeit: Transformationslogik ist klar getrennt
  • Typsicher: TypeScript-Typinferenz funktioniert
  • Hohe Wartbarkeit: Auswirkungsbereich bei Logikänderungen ist klar

Nachteile:

  • Erhöhter Code-Umfang (aber als Kompromiss für Klarheit)

Praktisches Beispiel: Vollständige Implementierung der API-Antwortverarbeitung

Hier ist ein praktisches Implementierungsbeispiel, das die bisherigen Muster kombiniert. Eine Implementierung mit der JSONPlaceholder-API.

typescript
/**
 * ViewModel-Typdefinition
 */
interface DataViewModel<T = any> {
  loading: boolean;
  data: T | null;
  error: {
    message: string;
    code?: string;
    retryable: boolean;
  } | null;
  info: string | null;
}

/**
 * Datenabruf-Service
 */
class DataService {

  /**
   * Datenabruf (mit automatischer Wiederholung)
   */
  fetchData<T>(postId: number = 1): Observable<DataViewModel<T>> {
    return fetchPost(postId).pipe(

      // Antwort in ViewModel konvertieren
      map(response => this.mapToViewModel<T>(response)),

      // HTTP-Fehlerbehandlung
      catchError((err: Error) =>
        of(this.createErrorViewModel<T>(err))
      ),

      // Automatische Wiederholung bei wiederholbaren Fehlern
      retry({
        count: 2,
        delay: 1000 // 1 Sekunde warten und wiederholen
      })
    );
  }

  /**
   * API-Antwort in ViewModel konvertieren (reine Funktion)
   */
  private mapToViewModel<T>(response: ApiResponse<T>): DataViewModel<T> {
    if (response.status === 'success') {
      return {
        loading: false,
        data: response.data || null,
        error: null,
        info: null
      };
    }

    if (response.status === 'error') {
      return {
        loading: false,
        data: null,
        error: {
          message: this.getErrorMessage(response.errorCode, response.message),
          code: response.errorCode,
          retryable: this.isRetryableError(response.errorCode)
        },
        info: null
      };
    }

    // Pending (tritt bei JSONPlaceholder API nicht auf, aber für zukünftige Erweiterung)
    return {
      loading: true,
      data: null,
      error: null,
      info: 'Wird verarbeitet...'
    };
  }

  /**
   * ViewModel aus HTTP-Fehler erstellen
   */
  private createErrorViewModel<T>(err: Error): DataViewModel<T> {
    return {
      loading: false,
      data: null,
      error: {
        message: err.message || 'Ein Fehler ist aufgetreten',
        code: 'FETCH_ERROR',
        retryable: true
      },
      info: null
    };
  }

  /**
   * Fehlermeldung abrufen
   */
  private getErrorMessage(errorCode?: string, fallbackMessage?: string): string {
    const messages: Record<string, string> = {
      'NOT_FOUND': 'Daten nicht gefunden (nicht existierende Post-ID)',
      'NETWORK_ERROR': 'Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.',
      'SERVER_ERROR': 'Serverfehler aufgetreten'
    };

    return messages[errorCode || ''] || fallbackMessage || 'Ein Fehler ist aufgetreten';
  }

  /**
   * Bestimmt, ob Fehler wiederholbar ist
   */
  private isRetryableError(errorCode?: string): boolean {
    const retryable = ['NETWORK_ERROR', 'SERVER_ERROR'];
    return retryable.includes(errorCode || '');
  }
}

/**
 * Datenanzeige-Manager
 */
class DataDisplayManager {
  private destroy$ = new Subject<void>();
  private dataService = new DataService();

  viewModel: DataViewModel = {
    loading: false,
    data: null,
    error: null,
    info: null
  };

  // Callback für UI-Update (optional)
  onStateChange?: (viewModel: DataViewModel) => void;

  init(): void {
    this.loadData();
  }

  loadData(postId: number = 1): void {
    this.viewModel = {
      loading: true,
      data: null,
      error: null,
      info: null
    };
    this.updateUI();

    this.dataService.fetchData(postId).pipe(
      takeUntil(this.destroy$)
    ).subscribe({
      next: viewModel => {
        // ✅ Nur einfache Zuweisung innerhalb von subscribe
        this.viewModel = viewModel;
        this.updateUI();
      }
    });
  }

  private updateUI(): void {
    if (this.onStateChange) {
      this.onStateChange(this.viewModel);
    }
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// Verwendungsbeispiel (kann in der Browser-Konsole ausgeführt werden)
const dataDisplay = new DataDisplayManager();
dataDisplay.onStateChange = (viewModel) => {
  if (viewModel.loading) console.log('Lädt...');
  if (viewModel.error) {
    console.error(viewModel.error.message);
    if (viewModel.error.retryable) console.log('Wiederholbar');
  }
  if (viewModel.info) console.log(viewModel.info);
  if (viewModel.data) console.log('Post-Daten:', viewModel.data);
};
dataDisplay.init();

// Ausgabebeispiel:
// Lädt...
// Post-Daten: {userId: 1, id: 1, title: "...", body: "..."}
//
// Beispiel bei Fehler (nicht existierende Post-ID 999 angegeben):
// dataDisplay.loadData(999);
// Lädt...
// Daten nicht gefunden (nicht existierende Post-ID)
// Wiederholbar

Punkte des praktischen Musters

  1. Transformation in Service-Schicht: Konvertierung von API-Antwort zu ViewModel in der Service-Schicht
  2. Verwendung reiner Funktionen: Logik für bedingte Verzweigungen als reine Funktionen extrahieren
  3. Einfaches subscribe: Nur Zuweisungen beim Abonnieren
  4. Zentralisierte Fehlerbehandlung: Fehlerverarbeitung innerhalb der Pipeline abschließen
  5. Gewährleistung der Typsicherheit: Sicherheit durch ViewModel-Typdefinition garantieren

Auswahlrichtlinien

Hier sind szenariobasierte Richtlinien, welches Muster gewählt werden sollte.

Musterauswahlkriterien

SzenarioEmpfohlenes MusterGrund
Einfache FilterungMuster 1 (filter + tap)Einfach und leicht verständlich
Erfolg/Fehler 2 VerzweigungenMuster 2 (partition)Klare Trennung möglich
Polling/WiederholungMuster 3 (switchMap + iif)Unterstützt dynamische Verarbeitung
Komplexe bedingte VerzweigungenMuster 4 (reine Funktion + map)Testbar, wiederverwendbar
Zustandsverwaltung erforderlichMuster 4 (reine Funktion + map)Geeignet für ViewModel-Muster

Im Zweifelsfall **Muster 4 (reine Funktion + map)** wählen

  • Höchste Wartbarkeit
  • Leicht testbar
  • Geeignet für Teamentwicklung
  • Gute Kompatibilität mit TypeScript-Typsystem

Zusammenfassung

Komplexe bedingte Verzweigungen innerhalb von subscribe sind eines der Anti-Patterns von RxJS. Durch Verwendung der 4 in diesem Artikel vorgestellten Muster können Sie deklarativen und wartbaren Code schreiben.

Wichtige Prinzipien

Prinzipien zur Vermeidung bedingter Verzweigungen in subscribe

  1. Logik innerhalb der Pipeline verarbeiten - Transformation vor subscribe abschließen
  2. Reine Funktionen verwenden - Logik für bedingte Verzweigungen als Funktionen extrahieren
  3. ViewModel-Muster übernehmen - In Modell für UI-Anzeige konvertieren
  4. Seiteneffekte mit tap trennen - Klare Trennung zwischen reiner Transformation und Seiteneffekten
  5. Typsicherheit gewährleisten - TypeScript-Typsystem maximal nutzen

Before / After Vergleich

Before (Anti-Pattern):

typescript
observable$.subscribe(value => {
  if (value.status === 'success') {
    if (value.data) {
      this.data = value.data;
    }
  } else if (value.status === 'error') {
    if (value.errorCode === 'AUTH_ERROR') {
      // ...
    } else if (value.errorCode === 'NOT_FOUND') {
      // ...
    }
  }
});

After (empfohlenes Muster):

typescript
observable$.pipe(
  map(response => mapToViewModel(response)),
  tap(viewModel => console.log('Verarbeitet:', viewModel))
).subscribe(viewModel => {
  this.viewModel = viewModel;
});

Verwandte Abschnitte

Testcode

Hier ist ein Testbeispiel für Muster 4 (reine Funktion + map).

typescript
describe('mapResponseToViewModel', () => {
  it('should map success response correctly', () => {
    const response: ApiResponse = {
      status: 'success',
      data: { id: 1, name: 'Test' }
    };

    const result = mapResponseToViewModel(response);

    expect(result.loading).toBe(false);
    expect(result.displayData).toEqual({ id: 1, name: 'Test' });
    expect(result.messageType).toBe('success');
    expect(result.showRetryButton).toBe(false);
  });

  it('should map error response with retryable error', () => {
    const response: ApiResponse = {
      status: 'error',
      errorCode: 'RATE_LIMIT'
    };

    const result = mapResponseToViewModel(response);

    expect(result.loading).toBe(false);
    expect(result.displayData).toBeNull();
    expect(result.messageType).toBe('error');
    expect(result.showRetryButton).toBe(true); // RATE_LIMIT ist wiederholbar
  });

  it('should map error response with non-retryable error', () => {
    const response: ApiResponse = {
      status: 'error',
      errorCode: 'NOT_FOUND'
    };

    const result = mapResponseToViewModel(response);

    expect(result.showRetryButton).toBe(false); // NOT_FOUND ist nicht wiederholbar
  });

  it('should map pending response', () => {
    const response: ApiResponse = {
      status: 'pending'
    };

    const result = mapResponseToViewModel(response);

    expect(result.loading).toBe(true);
    expect(result.messageType).toBe('info');
  });
});

describe('isRetryableError', () => {
  it('should identify retryable errors', () => {
    expect(isRetryableError('RATE_LIMIT')).toBe(true);
    expect(isRetryableError('TIMEOUT')).toBe(true);
    expect(isRetryableError('SERVER_ERROR')).toBe(true);
  });

  it('should identify non-retryable errors', () => {
    expect(isRetryableError('NOT_FOUND')).toBe(false);
    expect(isRetryableError('AUTH_ERROR')).toBe(false);
    expect(isRetryableError('VALIDATION_ERROR')).toBe(false);
  });
});

Warum Tests reiner Funktionen einfach sind

  • Keine Abhängigkeit von externem Zustand
  • Gleiche Eingabe liefert immer gleiche Ausgabe
  • Keine Seiteneffekte
  • Mocks oder Spies nicht erforderlich

Referenzressourcen

Weitere Lernressourcen zur Vermeidung bedingter Verzweigungen in subscribe.

Veröffentlicht unter CC-BY-4.0-Lizenz.