Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Reem Jaghlit
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Reem Jaghlit

ngModel Is Still Kind Of Broken In Angular 2.0.0

By Ben Nadel on

The other day, in a Twitter conversation about the difficulty in navigating the Angular 2 code base, I brought up the non sequitur opinion that the lack of an exported "UNINITIALIZED" value from the change-detection feature felt like a bug. Now, this isn't a new opinion; I've blogged about change-detection problems several times over the last 8 months. But, a lot has changed in the last 8 months as well, including the final release of Angular. So, I wanted to see if these issues have been remedied in the final release of Angular 2.0.0. Unfortunately, it seems that they still exist.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

Before we dive into some code, it's important to think about the role of a Control Value Accessor in Angular 2. A Control Value Accessor is the translation and synchronization layer that sits in between ngModel and the underlying Component implementation:

ngModel <---> Control Value Accessor <---> Component

Out of the box, Angular 2 ships with Control Value Accessors for Components that are provided by the Browser. Namely, HTML form controls like Input, Select, and Textarea. For example (not an exhaustive list):

ngModel <---> DefaultValueAccessor <---> Input:Text, Textarea
ngModel <---> CheckboxControlValueAccessor <---> Input:Radio
ngModel <---> RadioControlValueAccessor <---> Input:Radio

But, we can of course create our own Control Value Accessors to work with non-native Components. Or, in other words, to work with Angular 2 components:

ngModel <---> Control Value Accessor <---> Angular 2 Component

To keep with the precedence already set by the core value accessors, our custom value accessors should be separate from the Angular 2 components that they are synchronizing. This has three benefits:

  • It is consistent with the core value accessors; and consistency is generally a good thing.
  • It creates a clean separation of concerns - why should the component have to know about ngModel?
  • It makes the Angular 2 components more flexible: consumable with or without the FormsModule.

With this in mind, I'm going to create a custom Component for mood selection. This component won't know anything at all about ngModel or Forms - it just knows about its own template and control-flow logic:

  • // Declaring module interface so TypeScript compiler doesn't complain.
  • declare var module : { id: string };
  •  
  • // Import the core angular services.
  • import { ChangeDetectionStrategy } from "@angular/core";
  • import { Component } from "@angular/core";
  • import { EventEmitter } from "@angular/core";
  • import { OnChanges } from "@angular/core";
  • import { SimpleChange } from "@angular/core";
  • import { SimpleChanges } from "@angular/core";
  •  
  • // I provide a widget that allows 5-levels of mood to be selected. Each mood level is
  • // represented by a different ASCII emoticon. This component uses a one-way data-flow
  • // and the OnPush change-detection strategy.
  • // --
  • // NOTE: This component knows nothing about Forms.
  • @Component({
  • moduleId: module.id,
  • selector: "my-mood",
  • inputs: [ "value" ],
  • outputs: [ "valueChange" ],
  • changeDetection: ChangeDetectionStrategy.OnPush,
  • host: {
  • "[class.--happy]": "( value > 0 )",
  • "[class.--sad]": "( value < 0 )"
  • },
  • styleUrls: [ "./my-mood.component.css" ],
  • template:
  • `
  • <div class="__controls">
  •  
  • <a (click)="goSadder()" class="__sadder">sadder</a>
  • <a (click)="goHappier()" class="__happier">happier</a>
  •  
  • <span [ngSwitch]="value" class="__emoticon">
  • <template [ngSwitchCase]="-2"> :\`( </template>
  • <template [ngSwitchCase]="-1"> :( </template>
  • <template [ngSwitchCase]="0"> :| </template>
  • <template [ngSwitchCase]="1"> :) </template>
  • <template [ngSwitchCase]="2"> :D </template>
  • </span>
  •  
  • </div>
  •  
  • <div *ngIf="( changeCount > 4 )" class="__message">
  • You seem unsure &mdash; maybe you should talk to someone.
  • </div>
  • `
  • })
  • export class MyMoodComponent implements OnChanges {
  •  
  • public value: number;
  • public valueChange: EventEmitter<number>;
  •  
  • private changeCount: number;
  •  
  •  
  • // I initialize the component.
  • constructor() {
  •  
  • this.changeCount = 0;
  • this.value = 0;
  • this.valueChange = new EventEmitter();
  •  
  • }
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I emit a valueChange event to increase the mood level.
  • public goHappier() : void {
  •  
  • if ( ! this.isHappiest() ) {
  •  
  • this.valueChange.next( this.value + 1 );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I emit a valueChange event to decrease the mood level.
  • public goSadder() : void {
  •  
  • if ( ! this.isSaddest() ) {
  •  
  • this.valueChange.next( this.value - 1 );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I determine if the component is currently at the highest mood level.
  • public isHappiest() : boolean {
  •  
  • return( this.value === 2 );
  •  
  • }
  •  
  •  
  • // I determine if the component is currently at the lowest mood level.
  • public isSaddest() : boolean {
  •  
  • return( this.value === -2 );
  •  
  • }
  •  
  •  
  • // I get called any time the inputs are bound to new values.
  • public ngOnChanges( changes: SimpleChanges ) : void {
  •  
  • // Count any value changes after the initial binding.
  • // --
  • // NOTE: Since we only have ONE input, we know that the changes collection will
  • // always contain a "value" property; as such, we don't have to check for the
  • // key existence before checking its state.
  • if ( ! changes[ "value" ].isFirstChange() ) {
  •  
  • this.changeCount++;
  •  
  • }
  •  
  • }
  •  
  • }

When you look at this code, there are a couple of important things to notice:

  • It uses the OnPush change-detection strategy.
  • It enforces a one-way dataflow by emitting valueChange events.
  • It uses the ngOnChanges component life-cycle method in order to power its business logic.

And, of course, it doesn't make any reference to ngModel (a precedence set by the Input, Textarea, and Selects native browser components). That's where our custom Control Value Accessor comes into play. In this case, we need to create a sibling directive that matches on:

my-mood[ngModel]

... so that it provides the Control Value Accessor only when this MyMoodComponent is being used in conjunction with the imported and available FormsModule.

  • declare var module : { id: string };
  •  
  • // Import the core angular services.
  • import { ControlValueAccessor } from "@angular/forms";
  • import { Directive } from "@angular/core";
  • import { forwardRef } from "@angular/core";
  • import { NG_VALUE_ACCESSOR } from "@angular/forms";
  •  
  • // Import the application services.
  • import { MyMoodComponent } from "./my-mood.component";
  •  
  • // I provide the ControlValueAccessor implementation for the MyMood component.
  • // --
  • // WHY BREAK THE VALUE ACCESSOR OUT INTO A DIFFERENT DIRECTIVE?
  • // This is a good question with a simple answer: it allows the MyMood component to be
  • // used with or without the FormsModule module. If the MyMood component implemented its
  • // own value-accessor, then you would have to include the FormsModule along with the
  • // MyMood component, even if the application wasn't actually using forms at all. Not only
  • // does this make the usage more flexible, it creates a cleaner separation of concerns.
  • @Directive({
  • selector: "my-mood[ngModel]", // Notice [ngModel] selector.
  • providers: [
  • {
  • provide: NG_VALUE_ACCESSOR,
  • useExisting: forwardRef(
  • function() {
  •  
  • return( MyMoodFormDirective );
  •  
  • }
  • ),
  • multi: true
  • }
  • ],
  • host: {
  • "(valueChange)": "handleValueChange( $event )"
  • }
  • })
  • export class MyMoodFormDirective implements ControlValueAccessor {
  •  
  • private onChangeCallback: any;
  • private onTouchedCallback: any;
  • private target: MyMoodComponent;
  •  
  •  
  • // I initialize the directive.
  • constructor( target: MyMoodComponent ) {
  •  
  • this.target = target;
  •  
  • }
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I handle valueChange events emitted from the underlying DOM component.
  • public handleValueChange( newValue: number ) : void {
  •  
  • // With a Control Value Accessor, the whole intent is to synchronize the view-
  • // model with the state of the DOM (Document Object Model). However, in this
  • // case, since the "DOM" is actual another Angular component, we need to tell
  • // the underlying component to render the new value. This way, the target DOM
  • // will by synchronized with the change we are about to emit.
  • // --
  • // NOTE: This violates the one-way data flow; but, that's an expectation of the
  • // ngModel directive usage.
  • this.writeValue( newValue );
  •  
  • // Synchronize the value from the DOM up into the view-model.
  • this.onChangeCallback( newValue );
  •  
  • }
  •  
  •  
  • // I register the ngModel onChange callback.
  • public registerOnChange( callback: any ) : void {
  •  
  • this.onChangeCallback = callback;
  •  
  • }
  •  
  •  
  • // I register the ngModel onTouched callback.
  • public registerOnTouched( callback: any ) : void {
  •  
  • this.onTouchedCallback = callback;
  •  
  • }
  •  
  •  
  • // I write view-model values to the underlying DOM (synchronizing the value from
  • // the view-model down into the DOM).
  • public writeValue( value: any ) : void {
  •  
  • // Store the new value back into the MyMood component.
  • this.target.value = value;
  •  
  • // ------------------------------------------------------- //
  • // ---- BRIDGING THE ANGULAR 2.0.0. FUNCTIONALITY GAP ---- //
  • // ------------------------------------------------------- //
  •  
  • // At this point, Angular's ngModel implementation leaves things unfinished.
  • // Specifically, it doesn't:
  • //
  • // - Run change-detection (needed for the OnPush change detection strategy).
  • // - Run the ngOnChanges() life-cycle hook.
  • //
  • // You can bridge this gap yourself by manually running change-detection and
  • // invoking the ngOnChanges() life-cycle hook. The change-detection is easy;
  • // but, the ngOnChanges is not easy because the "isFirstChange()" implementation
  • // uses a non-exported value, "UNINITIALIZED", which we would have to hack to
  • // get it working.
  • //
  • // Read more: https://www.bennadel.com/blog/3092-creating-an-abstract-value-accessor-for-ngmodel-in-angular-2-beta-17.htm
  •  
  • }
  •  
  • }

As you can see, the Control Value Accessor is binding to the MyMoodComponent's (valueChange) event so as to synchronize the view-model with the DOM; then, it also provides a writeValue() implementation that synchronizes the DOM with the view-model. Thinking about the core value accessors, it's easy to see where the "target component" reference would be replaced with the "ElementRef" abstraction and how the component property access would be replaced with calls to the Renderer.

To test this, I use both an ngModel and a non-ngModel instance in my root component:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • @Component({
  • selector: "my-app",
  •  
  • // In the following template, notice that we have TWO my-mood components; one is
  • // powered by a simple property binding while the other is powered by ngModel.
  • // --
  • // NOTE: In both cases, we are using two-way data binding to allow the emitted
  • // value-change events to be piped right back into the value binding.
  • template:
  • `
  • <my-mood [(value)]="mood"></my-mood>
  • <my-mood [(ngModel)]="mood"></my-mood>
  • `
  • })
  • export class AppComponent {
  •  
  • public mood: number;
  •  
  •  
  • // I initialize the component.
  • constructor() {
  •  
  • this.mood = 0;
  •  
  • }
  •  
  • }

When we run this, however, we can see that something is clearly broken:


 
 
 

 
 ngModel still buggy in Angular 2 final release. 
 
 
 

Both components are powered by the same view-model value; however, if you interact with the non-ngModel one, the ngModel version doesn't update at all. This is because the OnPush change-detection stops it from picking up the changes unless you manually trigger change-detection.

That said, even if you manually trigger change-detection, it still doesn't work because the ngOnChanges() life-cycle method isn't invoked (which is why the "message" div doesn't show up). Of course, you could manually trigger the ngOnChanges() life-cycle method; but, this brings us back to the Twitter conversation:


 
 
 

 
Tweet with Pascal Precht about Angular 2 code and non-export of UNINITIALIZED data. 
 
 
 

In order for you to manually trigger the ngOnChanges() life-cycle method, you have to hack - and I mean really hack - the SimpleChanges collection.

Now, if the "UNINITIALIZED" value that Angular uses in the SimpleChange class was publicly available, you could argue that it's OK that we have to jump through all of these hoops in the Control Value Accessor; but, the fact that this isn't a publicly exported value clearly means that Angular is supposed to provide this functionality for you. And, the fact that it doesn't is why I would argue that the ngModel implementation is still a bit buggy in Angular 2.0.0.



Looking For A New Job?

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

@John,

I cannot remember. I know I've definitely brought this up to people and I think I've filed somewhat related issues about ngModel; but, its been such a long beta, I can't remember if there is a bug about this one specifically :| I'll see if I can find it. If not, I can file one.

Reply to this Comment

If you think that it's a bug, then you should definitely do that. If it's an old beta issue, then it was probably closed :)

Reply to this Comment

And, here's the Plunkr I used for the bug ticket: https://plnkr.co/edit/oS1qWN7x7nXlQ4oNwR5j?p=preview

Reply to this Comment

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.