Synchronous vs. Asynchronous EventEmitters In Angular 2 Beta 14
When I was trying to come up with a way to implement "controlled inputs" in Angular 2 Beta 11, I realized that the EventEmitter() class, used to power directive output bindings, internally emitted its values asynchronously by default. After looking at the documentation, I saw that I could override this asynchronous behavior by passing in "false" as an EventEmitter() constructor argument. Converting a directive's output binding to use this synchronous workflow has a very interesting impact on how it is integrated into the overall digest life-cycle. And, it was this impact that allowed me to implement truly "controlled" inputs that adhered to a one-way data flow architecture.
Run this demo in my JavaScript Demos project on GitHub.
CAUTION: I don't yet have a good mental model for how the digest works in Angular 2, so please take the following digest description with a grain of salt.
When events take place in an Angular 2 application, the digest / zone stabilization algorithm ripples down through the component tree checking values and invoking directive life-cycle event handlers. If an event, within a directive, emits a value asynchronously, it means that the output's event handler won't be invoked until after the current zone stabilization. Instead, it will be invoked in a future tick of the event loop which will, in turn, run its own digest / zone stabilization.
If, however, a directive's output binding emits values using a synchronous EventEmitter, it means that the event handler will be invoked immediately, which means that it can change values inside the current digest / zone stabilization. Needless to say, this will change the coordination between the output event handler and the other life-cycle event handlers.
To demonstrate, let's take a look at a really simple Counter component that accepts a [value] input and emits a (valueChange) event. When we run this demo, and click on this component, we can see how the EventEmitter configuration affects the timing of various console logs.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Synchronous vs. Asynchronous EventEmitters In Angular 2 Beta 14
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></lin>
</head>
<body>
<h1>
Synchronous vs. Asynchronous EventEmitters In Angular 2 Beta 14
</h1>
<h2>
EventEmitter( isAsync = true ) — Default Behavior
</h2>
<my-app>
Loading...
</my-app>
<!-- Load demo scripts. -->
<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/es6-shim.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/Rx.umd.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/angular2-polyfills.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/angular2-all.umd.js"></script>
<!-- AlmondJS - minimal implementation of RequireJS. -->
<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/almond.js"></script>
<script type="text/javascript">
// Defer bootstrapping until all of the components have been declared.
requirejs(
[ /* Using require() for better readability. */ ],
function run() {
ng.platform.browser.bootstrap( require( "App" ) );
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the root application component.
define(
"App",
function registerApp() {
// Configure the App component definition.
ng.core
.Component({
selector: "my-app",
directives: [ require( "Counter" ) ],
template:
`
<counter
[value]="count"
(valueChange)="handleCounter( $event )">
</counter>
`
})
.Class({
constructor: AppController
})
;
return( AppController );
// I control the App component.
function AppController() {
var vm = this;
// I hold the current count rendered by the counter component.
vm.count = 0;
// Expose the public methods.
vm.handleCounter = handleCounter;
// ---
// PUBLIC METHODS.
// ---
// I handle the valueChange event from the counter component.
function handleCounter( newCount ) {
console.info( "handleCounter - calling context." );
// For this demo, we're just going to store the emitted value
// back into the input binding so the component will update its
// internal value.
vm.count = newCount;
}
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a counter component that renders the provided value and emits a
// valueChange event.
define(
"Counter",
function registerCounter() {
// Configure the Counter component definition.
ng.core
.Component({
selector: "counter",
inputs: [ "value" ],
outputs: [ "valueChange" ],
host: {
"(click)": "handleClick()"
},
template:
`
Counter: {{ value }}
`
})
.Class({
constructor: CounterController,
// Define the life-cycle methods on the prototype so that they
// are picked up at run-time.
ngAfterContentChecked: function noop() {},
ngAfterViewChecked: function noop() {},
ngOnChanges: function noop() {}
})
;
return( CounterController );
// I control the Counter component.
function CounterController() {
var vm = this;
// I hold the value for the current count.
vm.value = 0; // @Input to be injected.
// I am the event stream for the valueChange output.
// --
// By default, the EventEmitter() class is asynchronous. Which means,
// subscribers are alerted to the emitted event in a future tick of
// the event loop. However, you can create synchronous EventEmitter
// instances by passing `false` into the constructor.
vm.valueChange = new ng.core.EventEmitter( /* isAsync = */ true );
// Expose the public methods.
vm.handleClick = handleClick;
vm.ngAfterContentChecked = ngAfterContentChecked;
vm.ngAfterViewChecked = ngAfterViewChecked;
vm.ngOnChanges = ngOnChanges;
// ---
// PUBLIC METHODS.
// ---
// I handle the click event and emit the desired value change.
function handleClick() {
// CAUTION: Accessing PRIVATE PROPERTY of EventEmitter for
// demonstration purposes only! Never do this for realz.
console.warn( "User clicked component ( isAsync = %s ).", vm.valueChange._isAsync );
// In order to adhere to a one-way data flow architecture, we
// have to emit a valueChange event rather than updating the
// count value internally. This way, it's up to the calling
// context as to whether or not the value is actually mutated.
vm.valueChange.next( vm.value + 1 );
}
// I get called after the component's content has been checked during
// the digest life-cycle.
function ngAfterContentChecked() {
console.log( "ngAfterContentChecked:", vm.value );
}
// I get called after the component's view has been checked during
// the digest life-cycle.
function ngAfterViewChecked() {
console.log( "ngAfterViewChecked:", vm.value );
}
// I get called any time the input bindings are updated.
function ngOnChanges() {
console.log( "ngOnChanges:", vm.value );
}
}
}
);
</script>
</body>
</html>
Notice that we are passing "true" into the EventEmitter() constructor within the Counter component's (valueChange) output binding:
vm.valueChange = new ng.core.EventEmitter( /* isAsync = */ true );
This is the default behavior of the EventEmitter(). But, I'm leaving it in place as an explicit constructor argument for the purposes of documentation. Now, when we run this version of the demo and click on the counter, we get the following output:
Notice that, with an asynchronous EventEmitter(), the output binding's change handler isn't invoked until after the component's ngAfterContentChecked() and ngAfterViewChecked() life-cycle event handlers have been invoked. This is because those two event handlers are invoked as part of the (click) event's digest / zone stabilization. The setTimeout() used by the asynchronous EventEmitter() triggers a future digest, which results in additional "content" and "view" life-cycle events.
Now, let's change the EventEmitter() to be synchronous:
vm.valueChange = new ng.core.EventEmitter( /* isAsync = */ false );
This time, when we run the demo and click on the counter, we get the following output:
As you can see, with the synchronous invocation of the output binding's event handler, the calling context is able to update the counter's input binding before the component's ngAfterContentChecked() and ngAfterViewChecked() life-cycle event handlers have been invoked. Also, since we are no longer using a setTimeout(), there's no triggering of a secondary digest.
For the most part, within an Angular 2 application, you'll likely never need to think about the timing of your output binding event handlers. But, when timing becomes critical for a particular problem, such as when implementing "controlled inputs," it's nice to know that the behavior of the EventEmitter() can be tweaked within Angular 2 Beta 14 directives.
Want to use code from this post? Check out the license.
Reader Comments
+1 !!! Interesting!
@All,
For what it's worth, Rob Wormald told me in a Tweet that they are about to update this functionality in the core:
https://twitter.com/robwormald/status/718869861083017216
... EventEmitter() will start by *synchronous* by default.
Oh yeah i read his tweet.