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();
}
}
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();
}
}
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();
}
}
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);
}
}
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.
