Goodbye to unsubscribe in Angular components
Many people in the Angular community are wondering about what the best way to unsubscribe from observables in components actually is. In this article we are going to solve that question. But first let us explain some fundamental criteria.
There is a reason why RxJS is integrated in Angular. Apart from the fact that it handles asynchrony in a far more composable way than promises, those lack the ability of cancellation. That is because promises are eager whereas observables are lazy. This means that the callback function included in a promise gets executed immediately, even before calling then()
.
Now what we usually do to cancel an observable is to use the unsubscribe()
method on its subscription. We have always been told not to forget about that to avoid memory leaks but actually the number of times that we will need to unsubscribe in a component class will substantially be reduced if we keep a couple of things in consideration:
Generally you do not need to unsubscribe from HTTP calls. This is true because either if the request is successful or if it fails our observable from the Angular
HttpClient
will complete and there will not be any leaks. Of course we can still use cancellation but only in special cases, e.g. debouncing requests.The
ActivatedRoute
and its observables are insulated from the Router itself so the Angular Router destroys a routed component when it is no longer needed and the injected ActivatedRoute dies with it. No need to unsubscribe from the activated route.You do not need to unsubscribe if you use RxJS methods that auto-complete the observable such as
take()
orof()
.If you are following the best practices you should always use the
AsyncPipe
in your templates. This will not only take care of the unsubscription but it will also allow you to set yourChangeDetectionStrategy
toOnPush
.
Nevertheless in some cases we cannot avoid to subscribe in components. This could happen for instance when need something from router events.
Initially this does not look like a big issue but imagine the impact in projects with hundreds of components and big teams. This obviously can lead us to a massive common pattern and therefore to the feared boilerplate.
Abstract class
A direct way of staying DRY in Typescript consists of using abstract classes. This works pretty good for example when you have different versions of the same component. You do not want to repeat the common members so you just abstract them.
We will not use this approach to solve the unsubscription problem. However, let us take a look at how we would do it:
import { OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
export abstract class SubscribedContainer implements OnDestroy {
destroyed$ = new Subject<void>();
/**
* DO NOT this.destroyed$.complete();
* It is not necessary:
* https://stackoverflow.com/questions/44289859/do-i-need-to-complete-a-subject-for-it-to-be-garbage-collected
*/
ngOnDestroy(): void {
this.destroyed$.next();
}
}
Using RxJS’s takeUntil
operator is a generally accepted strategy to unsubscribe. If we write a component that extends SubscribedContainer
we can use that strategy and get rid of the boilerplate by just piping like this:
pipe(takeUntil(this.destroyed$))
You may have heard about the issue with shareReplay
. In order to make sure that our observable does not get leaked to the next operator before it completes takeUntil
has to come last in the pipe sequence. The earlier version of shareReplay
forced takeUntil
to be placed as second-last. But since RxJS 5.5.0-beta.4 this bug is fixed and we can safely place our unsubscribing operator at the end.
If you want to make sure that your team does not forget about placing takeUntil
as last just lint it:
npm i rxjs-tslint-rules -D
This whole thing with the abstract class just works. But there is a caveat: inheritance is not scalable. A Typescript class can only be extended from exactly one other class. Imagine that you want to remove that unsubscription boilerplate from several container components that also require injecting common dependencies. You will be forced to create a version of SubscribedContainer
(e.g. SubscribedContainerWithRouter
) that inherits from the primary one.
Mixin class
An abstract class is therefore not the best pattern. Would not it be nice to be able to combine different component implementations at the same time without having to follow an inheritance sequence? Well, this is actually possible:
import { OnDestroy } from '@angular/core';
import { Constructor } from '@my-scope/my-ng-lib/constructor';
import { Subject } from 'rxjs';
// WARNING: THIS DOES NOT WORK IN ANGULAR 8 WITH AOT!
// HOWEVER, IT DOES WORK IN ANGULAR 9!
/**
* Mixin class to automatically unsubscribe in component classes.
*
* // constructor.ts
* export type Constructor<T = {}> = new (...args: any[]) => T;
*/
export const subscribedContainerMixin = <T extends Constructor>(base: T = class {} as T) =>
class extends base implements OnDestroy {
destroyed$ = new Subject<void>();
/**
* DO NOT this.destroyed$.complete();
* It is not necessary:
* https://stackoverflow.com/questions/44289859/do-i-need-to-complete-a-subject-for-it-to-be-garbage-collected
*/
ngOnDestroy(): void {
this.destroyed$.next();
}
};
Welcome to the world of mixin classes.
Essentially what is happening there is the same as in the abstract class only this time we are not extending from a parent but from a base. Mixins are just functions that return classes. The Constructor
type makes it possible.
export type Constructor<T = {}> = new (...args: any[]) => T;
Let us take a look at the implementation in the component:
import { Component, OnInit } from '@angular/core';
import { interval, Observable } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { subscribedContainerMixin } from '@my-scope/my-ng-lib/subscribed-container.mixin';
@Component({
selector: 'subscribed-container',
templateUrl: './subscribed-container.component.html'
})
export class SubscribedContainerComponent extends subscribedContainerMixin() implements OnInit {
// We use interval because it does not complete.
observable$: Observable<number> = interval(1000);
subscription$$: Subscription;
ngOnInit(): void {
this.subscription$$ = this.observable$
.pipe(tap(console.log), takeUntil(this.destroyed$))
.subscribe();
}
// If you need to do something on destroy in the component class.
// tslint:disable-next-line: use-lifecycle-interface
ngOnDestroy(): void {
// tslint:disable-next-line: no-unused-expression no-string-literal
super['ngOnDestroy'] && super['ngOnDestroy']();
console.log('this.subscription$$.closed in ngOnDestroy::', this.subscription$$.closed);
}
}
Now our component extends subscribedContainerMixin()
. By following this design we are also avoiding to write over and over again the destroyed$
property and the call to next()
in the OnDestroy
lifecycle hook, exactly as in the abstract class. However, on both cases if we declare a constructor() {}
we will need to type super()
inside it. But if you don’t declare it the Typescript compiler will take care of adding all that.
The good thing here is that we are getting an amazing perk with this new approach: we can combine several super classes!
export class ExtendedComponent extends subscribedContainerMixin(withRouter(withConfigService())) {}
Look at that: we would be combining a class that auto-unsubscribes with another one that injects the router and with another one that injects our service. Is not that awesome?
Even though you will most likely not want to write the ngOnDestroy()
method (since it is part of the boilerplate) if you ever need to add extra code to it you can do that by pasting this line first:
super['ngOnDestroy'] && super['ngOnDestroy']();
The lifecycle of the extended class overwrites the one of the super class so we need to call and execute the latter up front.
WARNING: believe or not, the implementation of the mixin class has a handicap, too: it does not work in Angular 8 with AOT compilation! This makes this technique useless for our Angular 8 projects, since we need to compile ahead-of-time for production.
HOWEVER: the mixin class does work in Angular 9!
Method decorator
So we need a solution for our Angular 8 projects. Did you know that you can attach a Typescript decorator to the ngOndestroy()
method to influence its behaviour? Take a look at this.
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
// WARNING: THIS DECORATOR DOES NOT WORK IN ANGULAR 9 WITH IVY.
/**
* Method decorator to automatically unsubscribe in component classes.
*
* @example
* ```ts
* ngOnInit(): void {
* this.observable$.pipe(takeUntil((this as any).destroyed$)).subscribe();
* }
*
* @ondestroy()
* ngOnDestroy(): void {
* // Optionally we can do anything we want here.
* }
*
*
- Only 2 conditions:
- 1)
ngOnDestroy(): void {}
must be present in the component class. 2) We can only access
this.destroyed$
as(this as any).destroyed$
. / export function ondestroy(): MethodDecorator { /*- This cannot be a symbol becase we need to access it
- in the component as
takeUntil((this as any).destroyed$)
. - Otherwise we would have to export the symbol itslef
- and import it in the component class. */ const destroyed$ = 'destroyed$';
return (target: Component & OnDestroy, propertyKey: string, descriptor: PropertyDescriptor) => { Object.defineProperty(target, destroyed$, { // tslint:disable-next-line: rxjs-finnish value: new Subject(), // This will prevent us from creating a new destroyed$ property in the component. // It will throw an error if we try to do that. writable: false, enumerable: true, configurable: true, }); const originalDescriptor = descriptor.value;
// This cannot be an arrow function // So that we get the correct context of
this
. descriptor.value = function() { target[destroyed$].next(); /**- Normally you would pass the method arguments to the function:
- ```ts
- originalDescriptor.apply(this, arguments);
- ```
- But ngOnDestroy() does not take any arguments. */ originalDescriptor.apply(this); }; }; } ```
If you are not familiar with the concept I truly recommend you to take a look at the official docs.
A method decorator takes 3 parameters:
target
: the class where the decorator belongs. In this case, our component.propertyKey
: the name of the method,ngOnDestroy
.descriptor
: the method itself expressed as a JavaScript descriptor.
A decorator is just a function. If the method decorator returns a value, that value will be used as descriptor. In the current implementation we are using decorator factory. That means we are returning a function and within that function both the target and the descriptor can be modified.
So we create the destroyed$
property from the decorator. The cool thing about this is that we can set it as writable: false
. That way, if the developer tries to create a property with the same name in the component class this person will get an error in the console.
Right after that we modify the descriptor value by assigning it to an anonymous function and in that function we can call target[destroyed$].next()
.
This triggers the execution of ngOnDestroy()
from the decorator but in order to also make this happen in the component class we need to call the original descriptor and bind its context to the correct this
. That is the reason why we cannot use an arrow function here.
Only 2 conditions are necessary for that in the component class:
ngOnDestroy
must be present.We can only access the subject property as
(this as any).destroyed$
.
Something like this:
So this solution does not get rid of all the boilerplate. And there is more bad news to it: the method decorator works in Angular 8 but not in Angular 9!
If you want to know why and what can be done about it in the new version of the framework just read the next article.
This is not over yet!