Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at dev.Objective() 2015 (Bloomington, MN) with: Gregory Bruce Jones
Ben Nadel at dev.Objective() 2015 (Bloomington, MN) with: Gregory Bruce Jones@greg_r_e )

Using ngModel With The OnPush Change Detection Strategy In Angular 2 Beta 11

By Ben Nadel on

Lately, I've been thinking a lot about Input bindings for Angular 2 directives and components; and, specifically, about the interaction contract that is assumed when a directive exposes a public property explicitly as an Input property. In my previous post, I looked at how to uphold the ngOnChanges() portion of this contract. But yesterday, I realized that there was another even more critical part of the Input contract: change detection. And, just as the use of ngModel complicated the ngOnChanges() life-cycle event method, so does it also complicate the execution of OnPush change detection.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

When an Angular 2 component's rendering can be based entirely on its Input bindings, you can gain a possible performance improvement by enabling OnPush change detection for that component. With an OnPush change detection strategy, Angular won't bother checking the component's view unless one of the Inputs also changes (or an event is emitted internally). In this way, entire portions of the component tree can be short-circuited in terms of change detection execution.

Clearly, the exposure of a public property as an Input binding has a profound and critical impact on the correct execution of an Angular 2 application. Which means, if we start updating a component's input bindings programmatically - such as with an ngModel value accessor - we absolutely have to ensure that change detection is executed at the appropriate time.

While this is a frustrating complexity of the ngModel directive, it's actually easier to accomplish manual change detection than it is to manually trigger the ngOnChanges() life-cycle event method. That's because we have the ability to inject the ChangeDetectorRef instance being used by the current component. With access to the ChangeDetectorRef, we can mark a component, and all of its ancestors, as "dirty" which means Angular will run change detection for the marked component Views even if their Inputs have not changed.

In an ngModel context, our value accessor bridge has access to the target component's ChangeDetectorRef because all three directives are actually attached to the same element. While the target component provides the view and the element node, both ngModel and the value accessor are directives attached to the target component. So, when the value accessor requires the ChangeDetectorRef, the dependency injector is giving it the same one that it provided to the target component (as there is only one Provider instance per element).

To see this in action, I've taken my previous ngOnChanges() demo and updated it so that the Toggle component uses OnPush change detection. This requires the value accessor to explicitly mark the component for change whenever it has to pipe a value back into the Toggle component's Input property.

NOTE: I walk through this interaction requirement much more explicitly in the above video.

  • <!doctype html>
  • <html>
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Using ngModel With The OnPush Change Detection Strategy In Angular 2 Beta 11
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></lin>
  • </head>
  • <body>
  •  
  • <h1>
  • Using ngModel With The OnPush Change Detection Strategy In Angular 2 Beta 11
  • </h1>
  •  
  • <my-app>
  • Loading...
  • </my-app>
  •  
  • <!-- Load demo scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/11/es6-shim.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/11/Rx.umd.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/11/angular2-polyfills.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/11/angular2-all.umd.js"></script>
  • <!-- AlmondJS - minimal implementation of RequireJS. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/11/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( "TOGGLE_DIRECTIVES" ) ],
  • template:
  • `
  • <toggle [(ngModel)]="toggleIsOn"></toggle>
  •  
  • <p>
  • <a (click)="toggleModel()">Toggle value</a>:
  •  
  • {{ ( toggleIsOn ? "Yes" : "No" ) }}.
  • </p>
  • `
  • })
  • .Class({
  • constructor: AppController
  • })
  • ;
  •  
  • return( AppController );
  •  
  •  
  • // I control the App component.
  • function AppController() {
  •  
  • var vm = this;
  •  
  • // I determine whether or not the toggle is currently on.
  • // --
  • // NOTE: We are using ngModel to bypass the one-way data flow and
  • // allow the Toggle component to update this value (so to speak).
  • vm.toggleIsOn = true;
  •  
  • // Expose the public methods.
  • vm.toggleModel = toggleModel;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I toggle the value feeding into the Toggle component.
  • function toggleModel() {
  •  
  • vm.toggleIsOn = ! vm.toggleIsOn;
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide all of the directives need to work the Toggle, including the
  • // ngModel variations.
  • define(
  • "TOGGLE_DIRECTIVES",
  • function registerToggleDirectives() {
  •  
  • return([
  • require( "Toggle" ),
  • require( "ToggleForNgModel" )
  • ]);
  •  
  • }
  • );
  •  
  •  
  • // I provide a Yes / No toggle component.
  • // --
  • // NOTE: Notice that the core Toggle component DOESN'T KNOW ANYTHING ABOUT
  • // NGMODEL or the concept of "value accessors." That's a good thing - it goes
  • // beyond the scope of responsibility for this component.
  • define(
  • "Toggle",
  • function registerToggle() {
  •  
  • // Configure the Toggle component definition.
  • ng.core
  • .Component({
  • selector: "toggle",
  • inputs: [ "value" ],
  • outputs: [ "valueChange" ],
  •  
  • // Since this component's template rendering depends entirely
  • // on its Input values, we should be able to switch on OnPush
  • // change detection. This way, Angular will only perform dirty
  • // checks on the template when the inputs change (or when an
  • // event is triggered internally).
  • changeDetection: ng.core.ChangeDetectionStrategy.OnPush,
  • host: {
  • "(click)": "handleClick()"
  • },
  • template:
  • `
  • {{ ( value ? "Yes" : "No" ) }}
  • &mdash;
  • toggled {{ changeCount }} times.
  • `
  • })
  • .Class({
  • constructor: ToggleController,
  •  
  • // Define the life-cycle methods on the prototype so that they
  • // are picked up at run-time.
  • ngOnChanges: function noop() {}
  • })
  • ;
  •  
  • return( ToggleController );
  •  
  •  
  • // I control the Toggle component.
  • function ToggleController() {
  •  
  • var vm = this;
  •  
  • // I am the event stream for the valueChange output.
  • vm.valueChange = new ng.core.EventEmitter();
  •  
  • // I keep track of how many times the toggle value has been changed.
  • vm.changeCount = 0;
  •  
  • // Expose the public methods.
  • vm.handleClick = handleClick;
  • vm.ngOnChanges = ngOnChanges;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I handle the internal click event on the component.
  • function handleClick() {
  •  
  • // When the user clicks on the toggle, we can't change the value
  • // directly - that would mess up the one-way data flow. Instead,
  • // we have to emit the value change event and let the calling
  • // context decide if it wants to respond by updating the inputs.
  • vm.valueChange.next( ! vm.value );
  •  
  • }
  •  
  •  
  • // I get called whenever the bound inputs have changed.
  • function ngOnChanges( changes ) {
  •  
  • // We're using the ngOnChanges() event to track how many times
  • // the toggle component has been toggled.
  • vm.changeCount++;
  •  
  • console.log(
  • "Changes [first: %s]: %s.",
  • changes.value.isFirstChange(),
  • changes.value.currentValue
  • );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // I provide an ngModel-enabled bridge for the Toggle component.
  • define(
  • "ToggleForNgModel",
  • function registerToggleForNgModel() {
  •  
  • // Configure the ToggleForNgModel directive definition.
  • ng.core
  • .Directive({
  • // Notice that we are only matching on instances of the Toggle
  • // component that also include the ngModel directive.
  • selector: "toggle[ngModel]",
  • host: {
  • "(valueChange)": "handleValueChange( $event )"
  • },
  •  
  • // When ngModel is being used, we need to create a bridge between
  • // the ngModel directive and the target component. That bridge
  • // has to implement the "value accessor" interface. In this case,
  • // we're telling Angular to use THIS DIRECTIVE INSTANCE as that
  • // value accessor provider. This means that the following
  • // controller needs to provide the value accessor methods:
  • // --
  • // * registerOnChange
  • // * registerOnTouched
  • // * writeValue
  • // --
  • // NOTE: You don't need the forwardRef() here because we are
  • // using ES5 instead of TypeScript. ES5 for the win!
  • providers: [
  • ng.core.provide(
  • ng.common.NG_VALUE_ACCESSOR,
  • {
  • useExisting: ToggleForNgModelController,
  • multi: true
  • }
  • )
  • ]
  • })
  • .Class({
  • constructor: ToggleForNgModelController
  • })
  • ;
  •  
  • ToggleForNgModelController.parameters = [
  • new ng.core.Inject( require( "Toggle" ) ),
  • new ng.core.Inject( ng.core.ChangeDetectorRef )
  • ];
  •  
  • return( ToggleForNgModelController );
  •  
  •  
  • // I control the ToggleForNgModel directive.
  • // --
  • // NOTE: Since this controller is performing double-duty as both the
  • // directive controller AND the valueAccessor (for ngModel), it is also
  • // implementing the value accessor interface.
  • function ToggleForNgModelController( toggle, changeDetectorRef ) {
  •  
  • var vm = this;
  •  
  • // As part of the value accessor "bridge" that this directive is
  • // providing, we need to be able to manually trigger the ngOnChanges
  • // life-cycle event on the target component. To do that properly, we
  • // need to keep track of when the first value is written so that we
  • // can announce it as the first SimpleChange instance.
  • var isFirstChange = true;
  •  
  • // Eventually, ngModel will register its own change hander. Until
  • // then, let's start with a no-op to keep the consumption uniform
  • // in the following code.
  • var onChange = function noop() {};
  •  
  • // Expose the public methods.
  • vm.handleValueChange = handleValueChange;
  • vm.registerOnChange = registerOnChange; // Value accessor interface.
  • vm.registerOnTouched = registerOnTouched; // Value accessor interface.
  • vm.writeValue = writeValue; // Value accessor interface.
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I handle the valueChange event coming out of the Toggle component.
  • // Since ngModel doesn't know about this event, we have to bridge the
  • // gap between the Toggle component and the ngModel directive.
  • function handleValueChange( newValue ) {
  •  
  • // When we invoke the onChange() value accessor method, ngModel
  • // already assumes that the DOM (Document Object Model) is in the
  • // correct state. As such, we have ensure that the Toggle
  • // component reflects the change that it just emitted (by piping
  • // the emitted value right back into the Toggle component).
  • // --
  • // NOTE: At this point, we are disregarding the one-way data flow
  • // paradigm. But, that's the WHOLE POINT OF NG-MODEL.
  • // toggle.value = newValue;
  • applyChangesToTarget( toggle.value, newValue );
  •  
  • // Tell ngModel so that it can synchronize its own internal model.
  • // --
  • // NOTE: If we wanted to, we could use this as an opportunity to
  • // "parse" the value into something more "model" oriented (such as
  • // parsing string values into numbers).
  • onChange( newValue );
  •  
  • }
  •  
  •  
  • // I register the onChange handler provided by ngModel.
  • function registerOnChange( newOnChange ) {
  •  
  • onChange = newOnChange;
  •  
  • }
  •  
  •  
  • // I register the onTouched handler provided by ngModel.
  • function registerOnTouched() {
  •  
  • // console.log( "registerOnTouched" );
  •  
  • }
  •  
  •  
  • // I implement the value input invoked by ngModel. When ngModel
  • // wants to update the value of the target component, it doesn't
  • // know what property to use (or how to transform that value into
  • // something meaningful for the target component). As such, we have
  • // to bridge the gap between ngModel and the input property of the
  • // Toggle component.
  • function writeValue( newValue ) {
  •  
  • // As we go to apply the changes to the underlying Toggle
  • // component, notice that we are casting the incoming value to
  • // a Boolean. In this way, we can use the Value Accessor as a
  • // value transformer.
  • applyChangesToTarget( toggle.value, !! newValue );
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I apply the given previous and next values to the underlying
  • // component. Part of that process involves making sure all of the
  • // right work-flows (such as change-detection) are initiated.
  • function applyChangesToTarget( previousValue, nextValue ) {
  •  
  • // Write the ngModel value to the toggle component.
  • // --
  • // CAUTION: Because we know that the Toggle component is not
  • // using any host bindings that depend on this value, we can
  • // safely avoid running into change errors:
  • // --
  • // ExpressionChangedAfterItHasBeenCheckedException
  • // --
  • // Normally, we probably shouldn't make these kinds of assumption.
  • // But, I am trying to keep the [already complex] demo simple.
  • // --
  • // Read more: http://www.bennadel.com/blog/3056-host-bindings-are-breaking-the-ngmodel-bridge-in-angular-2-beta-11.htm
  • toggle.value = nextValue;
  •  
  • // When we update the component's Inputs programmatically,
  • // Angular doesn't actually register this as an "Input change".
  • // This creates several complications. First, it means that the
  • // OnChanges() life-cycle event method won't be triggered
  • // implicitly on the target component. Second, it means that
  • // OnPush change detection won't be triggered. As such, we have
  • // to take care of fulfilling both of those responsibilities
  • // explicitly here within this value accessor bridge.
  •  
  • // Ensure that the entire ancestor component path is marked for
  • // change detection so that Angular will know to perform a check
  • // on the Toggle's View template.
  • changeDetectorRef.markForCheck();
  •  
  • // If the toggle component doesn't provide a hook for the life-
  • // cycle event, there's nothing we need to do.
  • if ( ! toggle.ngOnChanges ) {
  •  
  • return;
  •  
  • }
  •  
  • // If we made it this far, the toggle component is exposing an
  • // ngOnChanges() method. As such, we have to prepare the changes.
  • var changes = {
  • value: new ng.core.SimpleChange( previousValue, nextValue )
  • };
  •  
  • // Unfortunately, the Angular API doesn't seem to expose the
  • // necessary utility library that is used to denote the "first"
  • // simple change. As such, we have to hack this by overwriting
  • // the isFirstChange() instance method when we know that this
  • // is the first change we are sending to the toggle.
  • if ( isFirstChange ) {
  •  
  • isFirstChange = false;
  •  
  • changes.value.isFirstChange = function() {
  •  
  • return( true );
  •  
  • };
  •  
  • }
  •  
  • toggle.ngOnChanges( changes );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

The ngModel value accessor needs to update the Input property of the Toggle component in two different cases:

  • ngModel invokes the writeValue() method, indicating that the Toggle instance needs to be synchronized with an external source of data.
  • The Toggle instance emits a "valueChange" event and our value accessor needs to propagate that change back into the component.

In either case, the value accessor is calling its own applyChangesToTarget() method which, in turn, calls:

changeDetectorRef.markForCheck()

It's this call that marks the current component, and all of its ancestor components, as needing to be checked. This way, Angular will run change detection on the views. And, when we run this code, you can see that the changeCount - incremented by the ngOnChanges() life-cycle event method - is being rendered properly:


 
 
 

 
 Implementing OnPush change detection with a custom input control using ngModel and Angular 2 Beta 11. 
 
 
 

At this point, it may seem like using ngModel is more trouble than its worth. And, in some cases, that may very well be true. But, the payoff of using ngModel is that it can facilitate more robust form interactions. But, that's a huge topic unto itself. In the meantime, hopefully this helped demonstrate how to use OnPush change detection with ngModel value accessors in Angular 2 Beta 11.




Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.