codelord.net

Code, Angular, iOS and more by Aviv Ben-Yosef

Replacing $scope.$watch with Lifecycle Hooks

| Comments

It is not uncommon to have an Angular directive or component that needs to perform some work when its bounded inputs are changed.

And we all know watches are bad for performance, and that you should only use them when you really need them. But sometimes your code really needs them. What to do?

With Angular 1.5’s introduction of components, and the back-porting of lifecycle hooks from Angular 2, we have cleaner ways of achieving this.

Note: This post will use components, but lifecycle hooks are available in Angular’s directive as well. You can make use of this technique even if your team hasn’t moved to components yet, as long as you’re using Angular 1.5 or later.

Let’s look at a an example component. For brevity I’ll be using ES6’s arrow functions, but of course you don’t have to:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.component('chart', {
  templateUrl: 'chart.html',
  bindings: {
    dataSeries: '='
  },
  controller: function($scope) {
    this.$onInit = () => {
      $scope.$watch(() => this.dataSeries, () => this._updateChart());
    };

    this._updateChart = () => {
      // Some D3 code to update the chart
    };
  }
});

As you can see, this is a very basic component that wraps around some native D3 code to render a chart. Whenever its input binding, dateSeries, is changed the component re-renders the chart. And it keeps track of those changes using $scope.$watch.

Now, let’s make use of the $onChanges lifecycle hook. $onChanges is called automatically by Angular whenever an input binding is changed by the component’s parent.

A couple of important details to notice: $onChanges only works with one-way bindings (and @ bindings), which is what you should be using 99% of the time, and $onChanges is only triggered when the parent component reassigns the value. It will not be triggered if you reassign it inside the component itself.

So let us update our component to use one way bindings and $onChanges instead of a watch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.component('chart', {
  templateUrl: 'chart.html',
  bindings: {
    dataSeries: '<' // Changed to one way
  },
  controller: function() {
    this.$onChanges = (changes) => {
      if (changes.dataSeries) {
        this._updateChart();
      }
    };

    this._updateChart = () => {
      // Some D3 code to update the chart
    };
  }
});

That’s about it. We no longer need to inject $scope, which is always a good thing. And we also removed a watch: Angular has its own watch on the binding anyway in order to sync it between components, and we’re taking advantage of it.

Detecting the Initialization Call

Sometimes when using $watch we would like to treat the first time it is called differently, since $watch triggers immediately after starting a watch. The way we identify it would be to write code such as this:

1
2
3
4
5
$scope.$watch(() => this.foo,
  (newValue, oldValue) => {
    if (newValue === oldValue) return;
    this.updateView();
});

As you can see, we’d check if newValue is the same as oldValue, which is Angular’s way of telling us it’s the initial run of the watcher.

With $onChanges we have a clearer way of achieving this:

1
2
3
4
5
this.$onChanges = (changes) => {
  if (changes.foo && !changes.foo.isFirstChange()) {
    this.updateView();
  }
};

As you can see, the changes object comes with a handy isFirstChange() method.

Keeping Track of the Previous Value

Another useful capability of $watch is that whenever it was triggered it would supply our listener with both the current value and the previous value. This allows the code to compare them:

1
2
3
4
5
6
$scope.$watch(() => this.foo,
  (newValue, oldValue) => {
    if (newValue.date !== oldValue.date) {
      this.updateView();
    }
});

Fear not, the changes object still got you covered:

1
2
3
4
5
6
7
8
this.$onChanges = (changes) => {
  if (!changes.foo) return;
  if (changes.foo.currentValue.date === changes.foo.previousValue.date) {
    return;
  }

 this.updateView();
};

Gotcha: $onChanges and $onInit

It just so happens that Angular triggers the initial $onChanges right before calling the $onInit hook. You should be aware of that when you write your component’s lifecycle hooks and make sure that you don’t rely in $onChanges on anything that gets setup by $onInit, and if so, make sure to account for it on the first change call.

That’s it! You just got rid of some needless $scope.$watch calls. Better performance, and modern code – win!
Pat yourself on the back for me.

“Maintaining AngularJS feels like Cobol 🤷…”

You want to do AngularJS the right way.
Yet every blog post you see makes it look like your codebase is obsolete. Components? Lifecycle hooks? Controllers are dead?

It would be great to work on a modern codebase again, but who has weeks for a rewrite?
Well, you can get your app back in shape, without pushing back all your deadlines! Imagine, upgrading smoothly along your regular tasks, no longer deep in legacy.

Subscribe and get my free email course with steps for upgrading your AngularJS app to the latest 1.6 safely and without a rewrite.

Get the modernization email course!

Comments