OnPush "gotcha" when using Angular 2's FormControl()

I was recently writing a component where I had some input fields that a user could use to filter down a data set. Given this filtering could be an intensive operation I wanted to "debounce" the input control so that the filter wasn't run on every key stroke. If you're not familiar with what "debounce" means, the gist is that I don't want to react to changes in the input field until a certain amount of time has passed since the user changed the value. You typically debounce after something like 250 milliseconds, so the delay isn't annoying, but enough to be meaningful.

Now in Angular 1 where was a really nice way to debounce any input by using the ng-model-options (docs) directive like so:

<input type="text" name="userName"  
             ng-model="user.name"
             ng-model-options="{ debounce: 1000 }" />

This would automatically ensure that the user.name property wasn't updated until the input's value hadn't changed for a second. Let's talk about how we can do this in Angular 2, and something to watch out for...

Debounce in Angular 2

To debounce an input field with Angular 2 the approach I'll take here is to use the FormControl class provided by the brand new @angular/forms package. The FormControl class is one of the basic building blocks for forms, and replaces the Control class available in the Angular releases prior to RC2. The reason we use this class is that it can provide changes to the <input>'s value as an observable. We'll walk through a specific example in a minute, but briefly, in my component I can define a FormControl property and subscribe to its valueChanges observable (code) like so:

filter = new FormControl();

constructor() {  
    // Subscribe to changes for the value of the control
    //
    this.filter.valueChanges.subscribe((filter: number) => {
        // Do something with the updated value
      });
}

and then in my template I can associate an input field with that property using the [formControl] directive:

<input type="number" [formControl]="filter" />  

Now there are easier ways to bind the input fields to properties on your component, so why bother with this? Well it's so we can leverage all the rxjs operators available, and in this case they include debounceTime and distinctUntilChanged. Let's look at the real example to see how they're used.

The example

As with most of my articles I wrote a simple example to help me experiment with this idea. This example has a list of words that I wanted to filter down using a text box that the user can type into. I want to debounce the input and only fire when the value has actually changed, so that's where the rxjs operators come in. Here's the desired behavior, and in this demo notice that as I type the control never loses focus...it just updates after the debounce timeout completes (500ms in this case):

Note, I think the timeouts were lost a little when making the animated gif, but those last changes didn't happen until the 500ms time elapsed

Ok, so what really happened?

Now what really happened is I tried to implement this idea, and was presented with the following behavior. Notice that as I type there is code showing the filter is updated, but the view won't react until some other event happens (in this case I click/tab outside of the input so it loses focus):

Here's what I had for my valueChanges subscription (here the FormControl is called loremFilter):

    this.loremFilter.valueChanges
      .debounceTime(500)
      .distinctUntilChanged()
      .subscribe((filter: string) => {
        console.log("New lorem-filter:", filter);
        this.processLoremFilter(filter);
      });

...so this seemed really straight-forward, but just didn't work as I expected. The reason has to do with how Angular knows that it should check the component for changes. In particular, I had a "real-world" component that I wanted to use the OnPush change detection strategy on, and when that's used Angular updates to the view only when a few specific things happen. If you haven't seen it yet, go check out Pascal Precht's great article and talk on how it all works.

In my case when the subscription called the processLoremFilter() method the internal data of my component was indeed updated, but because this happened in the Subscribe handler, and I was using the OnPush strategy, Angular didn't see this as a trigger for running change detection. When I clicked, or tabbed, or pressed a key then change detection would run, and the list in the view would update.

There are two ways to resolve this:

  • Don't use the OnPush change detection strategy
  • Explicitly tell Angular to check for changes in my Subscribe handler

Let's explore that second option a bit more shall we?

The final demo

Once I found out what was happening things made a lot more sense, and to help show how to work around this I created this final demo:

This version of the example provides a checkbox to let me switch whether or not I want to manually trigger change detection within my Subscribe handler. This works because Angular provides something called the ChangeDetectorRef (docs) that can be injected into a component. An instance of ChangeDetectorRef can be used to manipulate how changes are handled in the component's tree. In our case I use it to force Angular to check for changes when it normally wouldn't.

Here's the AppComponent used for this demo, and recall this came from a more complex scenario so this is a bit contrived:

import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';  
import {REACTIVE_FORM_DIRECTIVES, FormControl} from '@angular/forms';

// We're using a couple operators from rxjs, so we need to import them
//
import "rxjs/add/operator/distinctUntilChanged";  
import "rxjs/add/operator/debounceTime";

@Component({
  selector: 'my-app',
  //
  // KEY IDEA
  //  Using the OnPush strategy will cause issues if you're not careful
  //  and still learning what will trigger Angular to check for changes
  //
  changeDetection: ChangeDetectionStrategy.OnPush,
  //
  // Just load the template so it's not muddy-ing up the component
  //
  templateUrl: "/app/app.template.html",
  //
  // Include the form directives from the new forms release
  //
  directives: [REACTIVE_FORM_DIRECTIVES]
})
export class AppComponent {  
  markForCheck = false;

  lorem = "Bacon ipsum dolor amet beef ribs sirloin short loin tenderloin turkey brisket shankle jowl pig leberkas. Tongue doner porchetta, cupim pork belly frankfurter cow chuck corned beef tenderloin flank alcatra jerky turducken meatloaf. Frankfurter beef ribs ham hock, pancetta cupim bresaola meatball ball tip tongue t-bone sausage ground round tenderloin strip steak. T-bone swine ball tip, sirloin landjaeger boudin turkey drumstick shankle meatball biltong filet mignon tail short ribs. Shank beef boudin filet mignon";
  filteredLorem: string[];
  loremFilter = new FormControl();

  constructor(
    // Inject an instance of Angular's change detector ref
    //
    changeDetectorRef: ChangeDetectorRef
  ) {
    this.filteredLorem = this.lorem.split(" ");

    // Subscribe to changes in the input using the valueChanges observable
    //  so we can use some nice rxjs operators
    //
    this.loremFilter.valueChanges
      .debounceTime(500)
      .distinctUntilChanged()
      .subscribe((filter: string) => {
        console.log("New lorem-filter:", filter);

        // Do the actual work to filter out words that don't match 
        //  the current filter value
        //
        this.processLoremFilter(filter);

        // Just leverage the boolean bound to the checkbox in the template
        //
        if (this.markForCheck) {
          // Here we use our change detector instance to tell Angular that this
          //  component should be checked for changes. Without this the updates
          //  just made above won't be reflected in the UI when using 'OnPush'
          //
          changeDetectorRef.markForCheck();
        }
      });
  }

  /**
   * Updates the filteredLorem array to only those words that contain the
   * provided filter. If the filter is empty, then the original lorem string
   * is used.
   */
  private processLoremFilter(filter: string): void {
    let split = this.lorem.split(" ");

    if (filter === "") {
      this.filteredLorem = split;
    }
    else {
      this.filteredLorem = split.filter((word) => { return word.indexOf(filter) > -1; });
    }
  }
}

Just to see this in action here's a final GIF where I show the difference in behaviors. Again note when the checkbox isn't checked, the list isn't updated until some other event outside of the observable happens:

Wrapping up

In this article I walked through a small "gotcha" I ran into while using observables to debounce <input> elements in my Angular 2 templates. I showed how using the OnPush change detection strategy can cause strange behavior if you're still learning how to use it properly. Finally, I showed how you can work around this behavior by using the ChangeDetectorRef class to manually mark a component to be checked for changes. All of the code for this example, and any others I've published, are available in my github repo:

https://github.com/sstorie/experiments/tree/master/angular2-onpush-form-control

Thanks for reading, and please leave any feedback or questions in the comments.

comments powered by Disqus