For a while now, I've been very, very sad that I cannot figure how to get ngModel to work in Angular 2 for custom input controls. This sadness has persisted from Beta 1 through to the current (at the time of this writing) Beta 11. Now, however, I've been able to narrow the source of my sadness down to host bindings. For some reason, the change-detection for host bindings works differently than the change-detection for views. And this difference causes ExpressionChangedAfterItHasBeenCheckedException errors to be thrown unless you duct-tape your workflow with some shenanigans like a setTimeout().
To demonstrate this, I've created a super simple Toggle component that accepts a "value" input and emits a "valueChange" event. Both the Toggle's view and its host bindings depend on the "value" input in order to render completely (with the view managing the markup and the host bindings managing CSS class attribution).
On its own, the Toggle component doesn't know anything about two-way data binding or the concept of ngModel. This keeps the responsibility of the Toggle component very focused and keeps it in adherence with the one-way data flow philosophy being used by Angular 2. As such, if we want to use ngModel with this Toggle component, we have to create a "bridge" between the two. This bridge is known as a "value accessor."
I don't want to get too much into the details about how ngModel or the value accessor bridge works as that is somewhat tangential to the point of this post. Really, what I want to do is simply demonstrate that this approach works well until you add the host bindings that depend on the input. Then, the whole thing breaks. That is, until you add some shady hackery like a setTimeout() call.
The following code is "working" code because I have both the host bindings and the setTimeout() call commented-out. To see this breaking, take a look at the video (where I uncomment those parts and run the page).
If you don't want to watch the video, here's what happens when I uncomment these two lines of code:
- // , "[class.toggled-on]": "value"
- // , "[class.toggled-off]": "! value"
... and then load the page:
As you can see, Angular 2 throws an ExpressionChangedAfterItHasBeenCheckedException error. This goes away if you add the setTimeout() in the value accessor's writeValue() method. But, that is clearly not a good thing.
The part of this that really confuses me is that both the component template and its host bindings depend on the "view" input. Yet, the view is able to re-render properly when ngModel tells it to write a new value. For some reason, only the host binding has trouble with this change. And, it's this divergent behavior that makes me think this is a bug. If this were a feature, it would also break for the view template.
Want to use code from this post? Check out the license.
I posted this update to the GitHub issue that relates to the underlying bug:
Hi Ben -
I converted your sample to TypeScript and then re-implemented in a different direction in which I forget about `[(ngModel)]` and create my own 2-way binding property, `[(toggle)]`.
No need for the custom ValueAccessorDirective in the middle which seems to play a role in your host update challenges.
It's a simpler way to go and all you lose, AFAIK, is the NgForm-family of controls and validators.
I've rolled my own two-way binding property a few times now and been happy with it. YMMV
I think I'm starting to lean that way as well. I love the idea of all of the robust form integration that you get with ngModel. But, the honest truth is that I've never really used any of it historically. I think it holds a lot of value; but, not using it has never held me back.
And, while all of the ngModel is a tremendous hurdle, the nice thing about it is that it can be added after the fact as something external to a target component. So, you can definitely design a component to just have value/valueChange or toggle/togggleChange interactions, another developer can always come along, create another directive with:
... and bridge the gap when / if they need to.
The most interesting question that all of this has brought to mind is - what "contract" is assumed when a component exposes a public property as an "Input" property. When a component exposes an Input, that component can then make assumption about things like ngOnChanges() and OnPush change detection. Which means that if ngModel gets thrown into the mix, the value accessor bridge has to bridge those contractual gaps ... which I think very few people will think about.
Oh sweet chickens, it looks like this was fixed on or by Angular 2 Beta 17!!
Life's a garden, dig it!
Sorry, I think I may have jumped the gun with that last statement - Angular 17 seems to have fixed the host bindings but broken something with OnPush change detection. Sadness.
Unless I'm totally messing something up.