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.
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.
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:
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:
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.
Want to use code from this post? Check out the license.
Now that I have a better understanding how how ngModel works and what a value accessor does, I took a stab a writing an *abstract* value accessor that factors-out like 95% of the work: