Cover photo by Tim Swaan on Unsplash.

This post walks through handling subscriptions in Angular components in a way that avoids duplicating teardown code across every component.

Common Approaches

To avoid memory leaks in Angular components, developers typically rely on two standard techniques for managing RxJS subscriptions:

Utilizing Subscription

@Component({
  selector: 'interval',
  templateUrl: './interval.component.html',
})
export class IntervalComponent implements OnInit, OnDestroy {
  // initialize `Subscription` object
  private readonly subscriptions = new Subscription();

  ngOnInit(): void {
    // add all subscriptions to it
    this.subscriptions.add(
      interval(1000)
        .pipe(map(i => `== ${i} ==`))
        .subscribe(console.log)
    );

    this.subscriptions.add(
      interval(2000)
        .pipe(map(i => `=== ${i} ===`))
        .subscribe(console.log)
    );
  }

  ngOnDestroy(): void {
    // unsubscribe from all added subscriptions
    // when component is destroyed
    this.subscriptions.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Leveraging a Destroy Subject

@Component({
  selector: 'interval',
  templateUrl: './interval.component.html',
})
export class IntervalComponent implements OnInit, OnDestroy {
  // initialize destroy subject
  private readonly destroySubject$ = new Subject<void>();

  ngOnInit(): void {
    interval(1000)
      .pipe(
        map(i => `== ${i} ==`),
        // unsubscribe when destroy subject emits an event
        takeUntil(this.destroySubject$)
      )
      .subscribe(console.log);

    interval(2000)
      .pipe(
        map(i => `=== ${i} ===`),
        takeUntil(this.destroySubject$)
      )
      .subscribe(console.log);
  }

  ngOnDestroy(): void {
    // emit destroy event when component is destroyed
    this.destroySubject$.next();
  }
}
Enter fullscreen mode Exit fullscreen mode

A shared flaw exists in both approaches: the extra property must be set up, and cleanup instructions need to be wired into the ngOnDestroy hook. Yet a more elegant strategy for handling subscriptions within Angular components is available.

Solution

Consolidate the cleanup logic into one spot by defining a Destroy class that inherits from the Observable class and satisfies the OnDestroy interface:

@Injectable()
export class Destroy extends Observable<void> implements OnDestroy {
  // initialize destroy subject
  private readonly destroySubject$ = new ReplaySubject<void>(1);

  constructor() {
    // emit destroy event to all subscribers when destroy subject emits
    super(subscriber => this.destroySubject$.subscribe(subscriber));
  }

  ngOnDestroy(): void {
    // emit destroy event when component that injects
    // `Destroy` provider is destroyed
    this.destroySubject$.next();
    this.destroySubject$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

After that, Destroy becomes available for injection at the component scope, and we gain access to it through the constructor:

@Component({
  // provide `Destroy` at the component level
  viewProviders: [Destroy]
})
export class IntervalComponent implements OnInit {
  // inject it through the constructor
  constructor(private readonly destroy$: Destroy) {}

  ngOnInit(): void {
    interval(1000)
      .pipe(
        map(i => `== ${i} ==`),
        // unsubscribe when `destroy$` Observable emits an event
        takeUntil(this.destroy$)
      )
      .subscribe(console.log);
  }
}
Enter fullscreen mode Exit fullscreen mode

The Destroy provider, when registered at the component level, becomes bound to that component's lifecycle, granting access to the ngOnDestroy hook from within it. Consequently, as the IntervalComponent gets torn down, the provider's own ngOnDestroy method fires automatically.

Conclusion

As a general rule, handling subscriptions manually in Angular components is something you'll want to steer clear of. For side effects that need to happen at the component level, lean on the effects from @ngrx/component-store and trust ComponentStore to safeguard against memory leaks. But if handling side effects directly in your components is your preference, reaching for the Destroy provider is a smart way to cut down on duplicating cleanup logic across different components.

Peer Reviewers