Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: George Murphy
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: George Murphy

Binding RxJS Observable Sources Outside Of The NgZone In Angular 6.0.2

By on

Last week, I took a look at tracking the scroll percentage (0-100) of the Document (or any arbitrary Element) using RxJS. In that post, all of the Observable events triggered by the "scroll" interaction were meaningful to the subscriber. And, as such, it made sense that every scroll interaction should trigger a change-detection digest in Angular. But, this got me thinking about RxJS and the change-detection life-cycle. As it turns out, RxJS is not entirely compatible with Zone.js, which is what Angular uses to manage its change-detection algorithm. This means that in order to bind RxJS Observables outside of the Angular zone, we need to apply an RxJS-specific patch to our Angular application.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

To be honest, my understanding of Zone.js is not terribly robust. I knew that it was based on monkey-patching the JavaScript runtime, where it overrode methods like setTimeout() and setInterval(). But, beyond that, I've just enjoyed the "magic" without asking too many questions.

Unfortunately, this understanding of Zone.js is a bit too simplistic as it clearly falls-short when dealing with some 3rd-party APIs, like RxJS. For example, if you try to setup an Observable using NgZone.runOutsideAngular(), Angular digests will be triggered as events are raised, regardless of whether or not you ever re-enter the Angular zone.

To deal with this incompatibility, the Zone.js repository contains "patch" files that you can import into your Angular application. These patch files will monkey-patch the associated 3rd-party API, allowing it to become Zone-aware. For RxJS, they provide the "zone.js/dist/zone-patch-rxjs" file.

CAUTION: At the time of this writing - as best I can tell - this RxJS patch file is not compatible with the latest version of RxJS. As such, you have to include the "rxjs-compat" module, which will make RxJS v6 compatible with 5.x module paths. From what I've read, this causes the entire RxJS library to get compiled into the app. As such, this patch should not be used haphazardly.

Once this patch is imported into your Angular application, you can - if need-be - setup an Observable outside of the Angular Zone while still allowing the subscriber callbacks to be invoked inside of the Angular Zone. To demonstrate this, I've created a simple Angular application that tracks mouse events using an RxJS stream. However, rather than listening to every single mouse-movement, the RxJS stream throttles the events to every 2,000-ms.

// Import the core angular services.
import { Component } from "@angular/core";
import { fromEvent } from "rxjs";
import { Injectable } from "@angular/core";
import { map } from "rxjs/operators";
import { NgZone } from "@angular/core";
import { Observable } from "rxjs";
import { OnInit } from "@angular/core";
import { throttleTime } from "rxjs/operators";

// By default, RxJS doesn't propagate the correct Zone through its observables. This
// import will attempt to patch the RxJS methods (in a way that I don't really
// understand), thereby allowing RxJS to be runnable outside of the Angular zone while
// the subscriber callback can still run inside the Angular zone.
// --
// CAUTION: At the time of this writing, using this patch file requires you to use the
// "rxjs-compat" module since this zone-patch references the RxJS observable methods and
// operators using the pre-v6 file paths.
// --
// NOTE: I'm only including this file in the app-component for the demo. You'd probably
// want to include this in your polyfill file or your app-module?
import "zone.js/dist/zone-patch-rxjs";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface MousePosition {
	viewport: MousePoint;
	document: MousePoint;
}

interface MousePoint {
	x: number;
	y: number;
}

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			Mouse Position:
		</p>

		<ul *ngIf="position">
			<li>
				<strong>Viewport:</strong>
				( {{ position.viewport.x }} , {{ position.viewport.y }} )
			</li>
			<li>
				<strong>Document:</strong>
				( {{ position.document.x }} , {{ position.document.y }} )
			</li>
		</ul>
	`
})
export class AppComponent implements OnInit {

	public position: MousePosition;

	private zone: NgZone;

	// I initialize the app-component.
	constructor( zone: NgZone ) {

		this.position = null;
		this.zone = zone;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called when Angular performs a change-detection digest.
	public ngDoCheck() : void {

		console.log( "ngDoCheck() :", Date.now() );

	}


	// I get called once after the inputs have been bound for the first time.
	public ngOnInit() : void {

		this.getMousePosition().subscribe(
			( mousePosition ) : void => {

				// NOTE: Angular will automatically run change-detection after this
				// callback is invoked, applying the updated position to the template,
				// thanks to the zone-patch.
				this.position = mousePosition;

			}
		);

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I get the current mouse position as an observable stream.
	private getMousePosition() : Observable<MousePosition> {

		// Because we are using the throttle() operators, we want to bind the RxJS source
		// outside of the Angular Zone. This way, every mouse-movement doesn't trigger a
		// change-detection digest. We only care about change-detection when we know the
		// view-model may actually change, which is inside the subscriber callback.
		var outsideStream = this.zone.runOutsideAngular(
			() => {

				var stream = fromEvent( document, "mousemove" ).pipe(
					// While the mouse-events are being triggered continuously on the
					// document (while the user is mousing-around), we only want to let
					// one event through every few seconds.
					throttleTime( 2000 ),
					map(
						( event: MouseEvent ) : MousePosition => {

							return({
								viewport: {
									x: event.clientX,
									y: event.clientY
								},
								document: {
									x: event.pageX,
									y: event.pageY
								}
							});

						}
					)
				);

				return( stream );

			}
		);

		return( outsideStream );

	}

}

Notice that I am importing the "zone.js/dist/zone-patch-rxjs" file at the top of my App component. In a non-demo situation, you'd probably want to import this file in a more global place, like your Application module file or your polyfill file. But, for the sake of simplicity, I'm keeping everything in one file.

Once the patch is applied, I can use the fromEvent() operator to listen for mouse movements outside of the Angular Zone. This will allow those events to be tracked without triggering change-detection digests, which we can verify with the ngDoCheck() life-cycle method.

Notice that I am never explicitly re-entering the Angular Zone. It appears that this Zone.js patch automatically invokes the Subscriber callback using the Subscriber's current zone. As such, the patch file re-enters the Angular Zone implicitly since our stream was subscribed-to within the Angular Zone.

Now, if we run this Angular application in the browser and move the mouse around, we get the following output:

Zone.js patch for RxJS allows RxJS to run outside of the Angular Zone.

As you can see, even as we move the mouse around wildly, causing many "mousemove" events to be emitted, our throttleTime() only lets a few of the events through to the subscriber. As such, only a handful of change-detection digests are triggered.

In contracts, here's what happens when we run the application without the Zone.js patch file:

Zone.js patch for RxJS allows RxJS to run outside of the Angular Zone.

As you can see, without the Zone.js patch file for RxJS applied, every single one of our "mousemove" events triggers a change-detection digest in Angular, even though we bound the "mousemove" event "outside" of the Angular Zone. You can watch the video for a better understanding of just how often the change-detection is running.

To be clear, I am not recommending that everyone start including the Zone.js patch file for RxJS in your Angular application. Not only does it appear to bloat the size of the Angular app (due to the full inclusion of the RxJS library); but, it seems unlikely that you run into too many situations in which you actually want to ignore events propagated down through an RxJS stream. That said, this is the first time I've every seen this patch file, so please take what I say here with a grain of salt.

The Zone usage in Angular is "magical". It "just works". Except that it doesn't always "just work" the way you might expect it to. The Zone.js library provides some additional patch files to fill in the gaps when dealing with 3rd-party libraries like RxJS. And, while I am not saying that these patch files are necessary, it certainly does make me want to become more cognizant of where the seams in my application are. And, about how Angular's change-detection manages events that cross those boundaries.

Want to use code from this post? Check out the license.

Reader Comments

21 Comments

Hi Ben,
If ngDoCheck runs regardless of change detection. To actually know if change detection is run you should add a method inside component and call it from templae e.g

inside your template add {{cdrRun()}} and in your component.ts add cdrRun() {console.log('cdr is running')}

this is more accurate than ngDoCheck

15,640 Comments

@Hassam,

Oh, that's an interesting idea. To be clear - and make sure I understand what you mean - you're saying that because the cdrRun() method is being executed as part of the View template, it is, by definition, called every time change-detection is run for that View. That's very clever. I'll have to let that marinate in the back of my head.

21 Comments

To be clear - and make sure I understand what you mean - you're saying that because the cdrRun() method is being executed as part of the View template, it is, by definition, called every time change-detection is run for that View.

yeah! thats exactly what I meant. I also noticed that ngDoCheck and ngOnChanges is always called, even if component is detached using changeDetectorRef.detach().

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel