How `runOutsideAngular` might reduce change detection calls in your app

When working with Angular, I often see people are confused about NgZone and zone.js itself. I think that many developers treat it like black box, because they assume that Angular handles change detection auto-magically and they don’t even have to care about zones. Well, in most cases that’s true, but… In this short post I will show You 3 cases where NgZone.runOutsideAngular might reduce significantly amount of change detection calls in Your app (which might improve overall performance). I won’t explain details about NgZone and zone.js in this post, because there a lot of resources about that, but I hope You will be inspired to learn more about this topic.

All examples from this post are available on Github here.


Frequent timers

Ok, let’s take a look at first case. It might occurs in every place where You fire timers frequently, like setInterval, setTimeout or requestAnimationFrame. Let’s say we have to do some canvas animation inside our angular app. Our animation loop is simple setInterval.

this.interval = window.setInterval(() => {
    this.setNextColor()
    this.paint();
}, 10);

Every 10ms our canvas is updated with new color. Looks fine, right? Now open console in devTools. Each time change detection is triggered, You will see log ‘Change detection triggered’.

Oh my, looks like something went wrong! How did it happened? By default, change detection is triggered every time when last function in call stack is executed — if it’s in the NgZone. (it’s actually very simplified, but basically that’s how it works). So every 10ms we put callback from setInterval on the call stack, and after execution of this function, change detection is triggered on the root component. Angular doesn’t care that we don’t need it at all! But, as You probably assume, there’s a simple way to fix it. You can just wrap it with NgZone.runOutsideAngular like this:

this.ngZone.runOutsideAngular(() => {
    this.interval = window.setInterval(() => {
        this.setNextColor()
        this.paint();
    }, 10)
});

And just enjoy clear console 😎 :

Why is that? Because now we run timers outside NgZone and angular isn’t even “aware” that we fire these timers.


Outside clicks

Second case: You want to react on click outside Your component. Typical situation for stuff like dropdown, select input or popup. So what You probably would do is to bind @HostListener to document and check if component host contains event target.

@HostListener('document:click', ['$event'])
onDocumentClick(e: MouseEvent) {
    if (!this.elRef.nativeElement.contains(e.target)) {
        this.dropdownOpened = false;
    }
}

Host listeners are great, but…

This cause additional change detection on each document click. If we have 10 dropdowns on page, it would make additional 10 change detection calls on each click! Let’s use our tools to improve that. Because event listener was bound in NgZone, after each callback, views has to be checked for changes, so we want to bind listener outside NgZone. We want to be aware of state changes when click triggers dropdown close, so we have to get back to the Zone when needed.

ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
        this.document.addEventListener(
'click',
this.onDocumentClick.bind(this)
);
    });
}
onDocumentClick(e: MouseEvent) {
    if (
!this.elRef.nativeElement.contains(e.target) &&
this.dropdownOpened
) {
        this.ngZone.run(() => {
            this.dropdownOpened = false;
        });
    }
}

Ok, now it looks better:


Non-angular components integration

Let’s move to third case, the last one I’m gonna show you. It’s about creating angular components based on vanilla js or jquery plugins packages. Let’s say we need color picker component. We can write it from scratch, but why reinvent the wheel if there are non angular, stable solutions? We can just wrap it in angular component and we’re ready to go. So we may do something like this:

ngOnInit() {
    $(this.colorPickerEl.nativeElement).spectrum({
        change: (c) => {
            this.change.emit(c.toHexString());
        }
    });
}

It works, but there’s one major drawback. Let’s see how many change detections are fired.

Change detection is fired after every time mousemove callback is finished. It’s because event listener was added in NgZone. To fix it, let’s just initialize plugin outside NgZone, and get back only if there’s a need to do it (e.g. we update angular component property value)

ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
        $(this.colorPickerEl.nativeElement).spectrum({
            change: (c) => {
                this.ngZone.run(() => {
                    this.change.emit(c.toHexString());
                });
            }
        });
    });
}

Let’s see how it behaves after this change:

Decent!


Summary

We examined 3 cases where runOutsideAngular can reduce significantly amount of changeDetection calls. In simple apps, You probably won’t spot the performance improvements, but in robust apps, or some specific cases it might be a nice boost. Notice that it required from us just a little tweak in our code and understating of zones. I hope that this will inspire You to increase your knowledge about zone.js and NgZone.