Complete Guide - Managing RxJs Subscriptions in Angular

A detailed guide covering various techniques and best practices for managing Observables and unsubscriptions in Angular applications.

Complete Guide - Managing RxJs Subscriptions in Angular

Every Angular developer has worked with Observables and understands the critical need to manage subscriptions effectively. While there are many blog posts and videos on how to unsubscribe from Observables in Angular, a comprehensive and clear guide covering all the different approaches is still missing.

In this article, we aim to fill that gap by providing a clear, complete guide to managing subscriptions in Angular. We will explore various techniques, best practices, and the tools available to ensure that your applications are not only performant but also maintainable in the long run.

Benefits of Managing Unsubscriptions in Angular

Effectively managing unsubscriptions in Angular offers several significant benefits:

  1. Prevents Memory Leaks: By ensuring you unsubscribe from Observables, you can prevent memory leaks and maintain control over your application’s memory usage. Failure to unsubscribe can lead to memory being unnecessarily consumed by long-living subscriptions.
  2. Enhances Performance: When you unsubscribe from an Observable, it stops emitting values, reducing the number of objects created and subsequently required to be garbage collected. This reduction can lead to improved performance for your application.
  3. Improves Code Readability: Properly managing unsubscriptions makes your code more readable and easier to understand. It becomes clear when and where Observables are unsubscribed, allowing for better maintainability and easier debugging.

Is it always imperative to unsubscribe from observables?

Not necessarily! But the most clever answer is: always unsubscribe. No exceptions. Don't even think twice about it.

Some Observables, such as those from HTTP requests or Router events, complete automatically and don't require explicit unsubscription. However, don't let this exception fool you. Adopting a consistent habit of unsubscribing will safeguard your applications from potential issues.

Wondering why this is so critical? Daniel Glejzner explains it admirably in his article.

Async Pipe

The most safe way to handle subscribing and rendering data in Angular is by using the AsyncPipe.

With the AsyncPipe, you don’t need to manually subscribe and unsubscribe from Observables, as it automatically handles unsubscription when the component is destroyed.

Another significant benefit is that the AsyncPipe makes your component fully reactive. It automatically calls the ChangeDetectorRef.detectChanges() method whenever a new value is retrieved, ensuring your component is up-to-date with the latest data. Without the AsyncPipe, you would need to manually trigger change detection if you use the ChangeDetectionStrategy.OnPush strategy.

@Component({
  selector: 'app-example', 
  template: `
    <div>
      <p>The latest data value is: {{ data$ | async }}</p>
    </div>
  `,
})
export class ExampleComponent {

  data$: Observable<number>;

  constructor(private exampleService: ExampleService) {
	  this.data$ = this.exampleService.getData();
  }
}

Basic unsubscribe

The most basic way to unsubscribe from an Observable is to call the unsubscribe method on a Subscription. This method is typically called in the ngOnDestroy() lifecycle hook.

@Component({...})
export class ExampleComponent implements OnDestroy {

  private subscription: Subscription;

  constructor(private exampleService: ExampleService) {
	  this.subscription = this.exampleService.getData().subscribe();
  }

  ngOnDestroy() {
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
} 

Multiple subscriptions with the add() Method

The Subscription class in Angular can handle multiple subscriptions, allowing you to manage them collectively. Using the add method on a Subscription object, you can group several subscriptions together. This way, you can unsubscribe from all of them in a single go.

import { Subscription } from 'rxjs';

let mainSubscription = new Subscription();

// Add multiple subscriptions
mainSubscription.add(observable1.subscribe());
mainSubscription.add(observable2.subscribe());
mainSubscription.add(observable3.subscribe());

// Unsubscribe from all using the main subscription
mainSubscription.unsubscribe();

Using takeUntil

In recent years, the takeUntil operator has become increasingly popular within the development community for a more effective and reactive approach to managing unsubscriptions.

The takeUntil operator allows you to automatically unsubscribe from an Observable when another Observable emits a value. This is especially useful in Angular, as you can tie the lifecycle of your subscriptions to events such as the destruction of a component.

@Component({...})
export class ExampleComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    this.someObservable$
      .pipe(takeUntil(this.destroy$))
      .subscribe();
  }

  ngOnDestroy() {
    // Emit a value to complete all subscriptions using takeUntil
    this.destroy$.next();
    this.destroy$.complete();
  }
}

BaseComponent - Inheritance with takeUntil

One effective approach to managing subscriptions in Angular is using inheritance in combination with the takeUntil method. This method allows you to write less repetitive code by centralizing the unsubscription logic in a base class that other components or services can extend.

export class BaseComponent implements OnDestroy {
  public destroy$ = new Subject<void>();

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
@Component({...})
export class ExampleComponent extends BaseComponent implements OnInit {

  ngOnInit() {
    this.someService.getData()
      .pipe(takeUntil(this.destroy$))
      .subscribe();
  }
}

Pitfall to Consider:

One of the pitfalls of this method is that if the component has its own ngOnDestroy method implemented, you need to ensure that super.ngOnDestroy() is called within the component's ngOnDestroy method.

ngOnDestroy() {
    super.ngOnDestroy(); 
    // Your additional cleanup logic here
}

takeUntilDestroyed

Recognizing the widespread adoption of the takeUntil() approach, the Angular team introduced a built-in operator called takeUntilDestroyed in Angular 16. This operator simplifies the takeUntil() approach.

The key to using the takeUntilDestroyed operator lies in understanding its context requirements:

  1. Injection Context: When used within an injection context, such as the constructor or class field declarations, you don't need any additional parameters.
  2. Outside Injection Context: If you wish to use takeUntilDestroyed outside of an injection context, you must explicitly provide a DestroyRef as a parameter to ensure proper cleanup.
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({...})
export class ExampleComponent implements OnInit, OnDestroy {
  someObservable$ = new BehaviorSubject<any>(null);

  destroyRef = inject(DestroyRef);

  // with injection context
  anotherObservable$ = this.someObservable$
    .pipe(takeUntilDestroyed())
    .subscribe();

    // with injection context
  constructor() {
    this.someObservable$.pipe(takeUntilDestroyed()).subscribe();
  }

  // without injection context
  ngOnInit() {
    this.someObservable$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
  }
}

DestroyRef

There is also the way to unsubscribe using DestroyRef and register the onDestroy lifecycle hook which will be run when the usual ngOnDestroy method is run.

import { Injectable, DestroyRef } from '@angular/core';

@Injectable(...)
export class AppService {
  private destroyRef = inject(DestroyRef);
  private router = inject(Router);

  // This method can be called from any part of the app
  // The subscriptions will be removed when the service is destroyed
  listenRouterEvents() {
    const subscription = this.router.events.pipe(...).subscribe();

    this.destroyRef.onDestroy(() => subscription.unsubscribe());
  }
}

Custom Decorator

In addition to the built-in tools provided by Angular, you can enhance your subscription management strategy by creating a custom decorator.

The @AutoUnsubscribe decorator will automatically detect and unsubscribe from all Subscription objects within your component or service when it is destroyed. Here's how to create and use this decorator:

// auto-unsubscribe.decorator.ts
import { OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';

export function AutoUnsubscribe(constructor: Function) {
  const originalDestroy = constructor.prototype.ngOnDestroy;

  constructor.prototype.ngOnDestroy = function () {
    for (const prop in this) {
      const property = this[prop];
      if (property && typeof property.unsubscribe === 'function') {
        property.unsubscribe();
      }
    }

    if (originalDestroy && typeof originalDestroy === 'function') {
      originalDestroy.apply(this, arguments);
    }
  };
}

Use the @AutoUnsubscribe decorator in your component to automatically manage unsubscriptions:

@AutoUnsubscribe
@Component({...})
export class ExampleComponent {
  private dataSubscription: Subscription;
  data: any;

  constructor(private dataService: DataService) {
    this.dataSubscription = this.dataService.getData().subscribe(response => {
      this.data = response;
    });
  }

  // ngOnDestroy is not necessary to implement manually here
}

Third Party Libaries

There are plenty of third-party libaries outside that offer solutions for managing subscriptions. But to be honest, we have so many built-in ways to get this job done. So there is no need to rely on external dependencies.

Don´t use first() or take()

The first() and take(1) operators are often used to automatically complete a subscription after emitting the first value. While this works well in simple cases, it doesn't offer the robustness needed for more complex applications. The major limitation is that these operators do not automatically unsubscribe when a component is destroyed. This can lead to unexpected behavior and potential memory leaks.

Here is the main issue: first() and take(1) complete the subscription only after the first value is emitted. If a component is destroyed before this emission (e.g., during a long-lasting HTTP request), the subscription remains active until the first value is received. This means:

  • Stale Subscriptions: The subscription stays active even if the component isn't, leading to unnecessary resource usage.
  • Unexpected Updates: The component might receive updates even when it has been destroyed, causing potential errors and unexpected behavior.
  • Potential Memory Leaks: Unreleased resources can accumulate over time, impacting the performance of the application.

Conclusion

Phew… That’s quite a long list of ways to handle subscriptions! However, the topic remains highly relevant, particularly because many Angular projects have not yet migrated to Signals. Understanding and correctly applying these different strategies is crucial.

If you have another approach, please let me know, and we will integrate it into this blog, giving you full credit for your contribution.