How to auto-unsubscribe in Angular 9
In a previous article we took a look at the different strategies on how to get rid of the typical unsubscription boilerplate in our component classes to make our project more scalable. Reading that article first could be a good idea to fully understand this one, but it is not essential.
Just to recap, this is the kind of boilerplate that we are talking about:
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { interval, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil, tap } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './boilerplate.component.html',
})
export class BoilerplateComponent implements OnInit, OnDestroy {
destroyed$ = new Subject<void>();
observable$: Observable<number> = interval(1000);
subscription$$: Subscription;
ngOnInit(): void {
this.subscription$$ = this.observable$
.pipe(tap(console.log), takeUntil(this.destroyed$))
.subscribe();
}
ngOnDestroy(): void {
this.destroyed$.next();
// Any extra actions:
console.log('this.subscription$$.closed in ngOnDestroy:::', this.subscription$$.closed);
}
}
The goal is removing these 3 things from any component class of our project:
destroyed$ = new Subject<void>();
this.destroyed$.next();
ngOnDestroy(): void {}
Of course number 3 is only to be removed in the case that we do not need to perform any other actions when destroying the component. If so, we need to be able to add more functionality to the method.
These are the strategies suggested in the previous article:
Abstract class: good and simple approach. Accomplishes 1, 2 and 3 but it is not scalable because a Typescript class can only be extended from exactly one other class.
Mixin class: like the abstract class but scalable. A component class can extend several mixin classes. Only one caveat: it does not work in Angular 8 with AOT compilation. However, it does work in Angular 9 so it is fine if you are just not using Angular 8.
Method decorator: it works in Angular 8 but not in Angular 9 and it forces us to write
ngOnDestroy()
in every component.
In this article we will observe a new revolutionary approach: unsubscribe with a Typescript class decorator that uses Ivy to achieve that ambition.
If you want to implement this approach out of the box, you don’t need to keep reading. Just install the library. Only for Angular 9+:
https://www.npmjs.com/package/ngx-hocs-unsubscriber
But if you want to know how that mechanism works keep reading.
Ivy
But why does the last strategy fail in Angular 9. What’s the problem? Well, basically this version of the framework is enabling the Ivy renderer by default and in this new context our project is getting built using 2 compilers:
Angular compatibility compiler or
ngcc
: it process the code coming fromnode_modules
to produce the equivalent Ivy version. This compilation is necessary to achieve backwards compatibility.Angular Typescript compiler or
ngtsc
: it transpiles our Angular Typescript code to JavaScript and reifies Angular decorators into static properties. You can understand it as a wrapper or the normal Typescript compiler (tsc
) that includes a set of Angular transforms.
Now, this ngtsc
is the reason why our method decorator does not work as expected. Instead of getting a normal Typescript compilation we have something a little different.
Let’s log for a second our method decorator target, which is the component itself. In Angular 8 you get this output:
So far so good. Now let’s see what happens if we do the same in Angular 9:
Interesting. Now our component constructor is getting a couple of new things. But just mind these 2:
ɵcmp: Ivy’s component definition.
ɵfac: Ivy’s component factory.
Did you see that both start with the very same strange character? That is the Greek letter theta and it is indicating us that those properties belong to Ivy’s private API. That means that there is absolutely not assurance that those names will remain. The idea is that as soon as we know that Angular 9 Ivy is totally backwards compatible those properties will drop the theta and become simply cmp
and fac
but currently we cannot rely on that naming convention.
However, whatever they are called in the future, those 2 guys are going to allow Angular to leverage the power of higher-order components and that leads us to our 4th strategy to remove the unsubscription boilerplate.
Unsubscriber decorator
The concept of higher-order components, or hocs for short, comes from the React world. Instead of using inheritance like in our 2 first strategies, hocs modify the behaviour of a component. In Typescript this can beautifully be achieved through class decorators and we are about to see the role that Ivy plays in all this.
DISCLAIMER: this technique does not reflect Angular teams’s official vision. However, it works in production in a real project.
import { ɵComponentDef, ɵComponentType } from '@angular/core';
import { Subject } from 'rxjs';
// TODO: move models to their own file.
// We need this interface override the readonly keyword
// on the properties that we want to re-assign.
export interface ComponentDef<T> extends ɵComponentDef<T> {
factory: FactoryFn<T>;
onDestroy: (() => void) | null;
}
// tslint:disable-next-line interface-over-type-literal
export type FactoryFn<T> = {
<U extends T>(t: ComponentType<U>): U;
(t?: undefined): T;
};
export type ComponentType<T> = ɵComponentType<T>;
/**
* Class decorator to automatically unsubscribe in component classes on Angular 9 with Ivy.
*
* @example
*
* @Unsubscriber()
* @Component({})
* export class MyContainerComponent {
* ngOnInit(): void {
* this.observable$.pipe(takeUntil((this as any).destroyed$)).subscribe();
* }
*
* ngOnDestroy(): void {
* // Optionally we can do anything we want here.
* }
* }
*
*
* Only 1 condition: we need to access `this.destroyed$` as `(this as any).destroyed$`.
* `ngOnDestroy(): void {}` does not have to be present in the component class! :)
*/
export function Unsubscriber(): any {
return (cmpType: ComponentType<any>) => {
const cmp: ComponentDef<typeof cmpType> = getComponentProp(cmpType, 'ɵcmp');
const cmpOndestroy: (() => void) | null = cmp.onDestroy;
cmpType.prototype.destroyed$ = new Subject<void>();
// This cannot be an arrow function
// So that we get the correct context of `this`.
cmp.onDestroy = function () {
(this as any).destroyed$.next();
/**
* Normally you would pass the method arguments to the function:
*
* cmpOndestroy.apply(this, arguments);
*
* But ngOnDestroy() does not take any arguments.
*/
if (cmpOndestroy !== null) {
cmpOndestroy.apply(this);
}
};
};
}
// TODO: move utils to their own file.
export function getComponentProp<T, K extends keyof T>(t: ComponentType<T>, key: string): T[K] {
if (t.hasOwnProperty(key)) {
return t[key];
}
throw new Error('No Angular property found for ' + t.name);
}
We talked a little about method decorators in the previous article. In this case we are using a class decorator, which only takes 1 argument: the target. In our case we call it cmpType
because it refers to our component.
The first thing we need to do is to retrieve one of the properties mentioned above: cmp
. In order to achieve that ambition we create a getComponentProp
utility function. And it is important that we throw an error in the case that those properties do not exist in our component constructor, since this API is private and maybe to be renamed or changed in the future.
But that is not all. We are going to need to modify a property that belongs to cmp
: onDestroy
. The problem is that that property is readonly
in the Angular core. So we create our own ComponentDef
interface to override that. Both models and utils should be moved to their own files in a real project but for the purpose of this article everything has been placed in the same one.
Now, cmpOnDestroy
stores the original onDestroy
. We will need that later.
And now it comes the real trick: we extend the component prototype by adding a new property:
cmpType.prototype.destroyed$ = new Subject<void>();
Thanks to that a new instance of our component will have the destroyed$
property included. But we still need to unsubscribe and for that we also need to modify cmp.onDestroy
as we predicted.
Here the implementation is similar to the one we employed in the method decorator. We call .next()
and we trigger the original onDestroy
but this time we do it conditionally, since in this case it is not mandatory that the ngOnDestroy()
method is present in the component class.
cmp.onDestroy = function () {
(this as any).destroyed$.next();
if (cmpOndestroy !== null) {
cmpOndestroy.apply(this);
}
};
The implementation is amazingly elegant:
@Unsubscriber()
@Component({})
export class MyContainerComponent {}
And works like a charm. With this we are fulfilling points 1, 2 and 3 as mentioned in the beginning as well as optionally being able to perform actions in ngOnDestroy()
.
Conclusion
Use the method decorator if you are working in Angular 8 and the Unsubscriber()
class decorator if you are in Angular 9. There is a lot to be discussed yet about meta-programming with Ivy but this higher-order component approach gives us a glimpse of the reach of Ivy’s potential.
Play with it
You can test the first 3 strategies in Angular 8 with this repo:
https://github.com/kaplan81/auto-unsubscribe-ng8
And the hoc in Angular 9 plus the other 3 with this other repo:
https://github.com/kaplan81/auto-unsubscribe-ng9
You will find instruction in the README files.
NOTE (date 20200210): the first one uses Angular 8.3.24 and the second Angular 9.0.0.