Skip to content

finalize

The finalize operator lets you specify a callback function that will be executed when the source Observable terminates. Termination happens in one of three ways:

  1. The Observable completes successfully (sends its last value and the complete notification).
  2. The Observable emits an error notification.
  3. The subscription to the Observable is unsubscribed (e.g., manually, or automatically via operators like take, takeUntil, or takeUntilDestroyed).

Think of it like the finally block in a traditional try...catch...finally statement. The code inside finalize is guaranteed to run after the Observable finishes its work or is stopped, regardless of why it stopped (success, error, or unsubscription).

Key Points#

  1. Guaranteed Execution on Termination: Runs whether the stream succeeds, fails, or is unsubscribed.
  2. No Arguments: The callback function you provide to finalize receives no arguments. It doesn't know if an error occurred or what the last value was; it just knows the stream is done.
  3. Side Effects Only: Like tap, finalize is purely for side effects. It doesn't affect the values, errors, or completion signals passing through the stream (because it runs after them).
  4. Ideal for Cleanup: Its primary purpose is resource cleanup or actions that must happen when an operation is finished.

Why Use finalize?#

The most common and important use case is managing loading states.

  • You start an operation (e.g., HTTP request).
  • You set a loading flag/signal to true.
  • The operation might succeed or fail.
  • You need to ensure the loading flag/signal is always set back to false when the operation is finished, no matter the outcome. finalize is perfect for this.

Other uses include:

  • Closing connections (though often handled by unsubscription itself).
  • Logging the end of an operation.
  • Releasing any temporary resources acquired at the start of the subscription.

Real-World Example: Managing Loading State for Data Fetching#

This is the classic example. We fetch data, show a loading indicator, and use finalize to hide the indicator when the fetch completes or fails.

Code Snippet#

import {
  Component,
  inject,
  signal,
  ChangeDetectionStrategy,
  DestroyRef,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Observable, of, timer } from "rxjs";
import { delay, switchMap, tap, catchError, finalize } from "rxjs/operators";
import { EMPTY } from "rxjs"; // Import EMPTY

// --- Mock Data Service ---
interface Product {
  id: number;
  name: string;
  price: number;
}

function mockFetchProducts(
  failRequest: boolean = false
): Observable<Product[]> {
  console.log("Backend: Starting simulated product fetch...");
  if (failRequest) {
    // Simulate a delayed error
    return timer(1200).pipe(
      tap(() => console.log("Backend: Simulating network error...")),
      switchMap(() => {
        throw new Error("Network Error: Failed to connect to server.");
      })
    );
  } else {
    // Simulate a successful response with delay
    const products: Product[] = [
      { id: 101, name: "Super Widget", price: 19.99 },
      { id: 102, name: "Mega Gadget", price: 29.95 },
    ];
    return of(products).pipe(
      delay(1500), // Simulate network latency
      tap(() => console.log("Backend: Simulated fetch successful."))
    );
  }
}
// --- End Mock Data Service ---

@Component({
  selector: "app-product-list",
  standalone: true,
  imports: [CommonModule],
  template: `
    <div>
      <h4>Product List (Finalize Example)</h4>
      <button (click)="loadProducts(false)" [disabled]="loading()">
        Load Products
      </button>
      <button (click)="loadProducts(true)" [disabled]="loading()">
        Load Products (Simulate Error)
      </button>

      @if (loading()) {
      <p class="status loading">Loading products...</p>
      } @if (errorMessage()) {
      <p class="status error">Error: {{ errorMessage() }}</p>
      } @if (products().length > 0 && !loading()) {
      <ul>
        @for(product of products(); track product.id) {
        <li>{{ product.name }} - {{ product.price | currency }}</li>
        }
      </ul>
      } @else if (!loading() && !errorMessage()) {
      <p>Click button to load.</p>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductListComponent {
  private destroyRef = inject(DestroyRef);

  // --- State Signals ---
  loading = signal<boolean>(false);
  products = signal<Product[]>([]);
  errorMessage = signal<string | null>(null);

  loadProducts(simulateError: boolean = false): void {
    if (this.loading()) return; // Don't load if already loading

    this.loading.set(true); // <-- Start Loading Indicator
    this.products.set([]);
    this.errorMessage.set(null);
    console.log("UI: Fetch initiated, showing loading state.");

    mockFetchProducts(simulateError)
      .pipe(
        tap((data) =>
          console.log("UI Stream: Received product data (before finalize)")
        ),
        catchError((err: Error) => {
          console.error("UI Stream: Error caught:", err.message);
          this.errorMessage.set(err.message || "Could not load products.");
          // Return EMPTY to allow finalize to run after error handling
          return EMPTY;
        }),
        // --- Key Operator ---
        // This block runs AFTER success (tap), OR AFTER error (catchError),
        // OR if the subscription is cancelled (e.g., by takeUntilDestroyed).
        finalize(() => {
          this.loading.set(false); // <-- Stop Loading Indicator
          console.log(
            "UI: Finalize block executed - Loading state set to false."
          );
        }),
        // --------------------
        takeUntilDestroyed(this.destroyRef) // Ensure unsubscription on destroy
      )
      .subscribe({
        next: (data) => {
          console.log("UI: Subscribe next - updating product list.");
          this.products.set(data);
        },
        // Error already handled by catchError
        error: (err) => {
          /* No need for code here usually if catchError handles UI state */
        },
        // Complete isn't needed for loading state because finalize covers it
        complete: () => {
          console.log("UI: Subscribe complete.");
        },
      });
  }
}

Explanation:

  1. When loadProducts is called, loading is immediately set to true, displaying the "Loading products..." message.
  2. mockFetchProducts returns an Observable that simulates either success or failure after a delay.
  3. The pipe chain processes the result:
    • tap: Logs successful data receipt (only runs on success).
    • catchError: Catches any error from the source. It sets the errorMessage signal and returns EMPTY. Returning EMPTY makes the stream complete gracefully after the error, ensuring finalize still runs.
    • finalize(() => { this.loading.set(false); }): This is the crucial part. This callback function is registered to run when the stream terminates.
    • If mockFetchProducts succeeds, the next value passes through tap, then the stream completes. finalize runs, setting loading to false.
    • If mockFetchProducts fails, the error goes to catchError. catchError handles it and returns EMPTY, which immediately completes the stream. finalize runs, setting loading to false.
    • If the component is destroyed while the fetch is in progress, takeUntilDestroyed triggers unsubscription. finalize runs, setting loading to false.
    • takeUntilDestroyed: Handles automatic unsubscription.
  4. The subscribe block's next handler updates the products signal only on success.

No matter what happens – success, failure, or component destruction – the finalize block ensures that this.loading.set(false) is called, correctly cleaning up the UI loading state. This makes it much more reliable than trying to manage the loading flag in both the error and complete/next handlers of the subscribe block.