28 July 2014

AngularJS + TypeScript – how to setup a watch (and 2 ways to do it wrong)

Introduction

After setting up my initial application as described in my previous post, I went about to set up a watch. For those who don’t know what that is – it’s basically a function that gets triggered when a scope object or part of that changes. I have found 3 ways to set it up, and only one seems to be (completely) right.

In JavaScript, you would set up a watch like this sample I nicked from Stack Overflow:

function MyController($scope) {
   $scope.myVar = 1;

   $scope.$watch('myVar', function() {
       alert('hey, myVar has changed!');
   });
   $scope.buttonClicked = function() {
      $scope.myVar = 2; // This will trigger $watch expression to kick in
   };
}

So how would you go about in TypeScript? Turns out there are a couple of ways that compile but don’t work, partially work, or have unexpected side effects.

For my demonstration, I am going to use the DemoController that I made in my previous post.

Incorrect method #1 – 1:1 translation.

/// <reference path="../scope/idemoscope.ts" />
/// <reference path="../scope/person.ts" />
module App.Controllers {
    "use strict";
    export class DemoController {

        static $inject = ["$scope"];

        constructor(private $scope: Scope.IDemoScope) {
            if (this.$scope.person === null || this.$scope.person === undefined) {
                this.$scope.person = new Scope.Person();
            }
            this.$scope.$watch(this.$scope.person.firstName, () => {
                alert("person.firstName changed to " +
                    this.$scope.person.firstName);
            });
        }

        public clear(): void {
            this.$scope.person.firstName = "";
            this.$scope.person.lastName = "";
        }
    }
} 

The new part is in red. Very cool – we even use the inline ‘delegate-like’ notation do define the handler inline. This seems plausible, but does not work. What it does is, on startup, give the message “person.firstName changed to undefined” and then it never, ever does anything again. I have spent quite some time looking at this. Don’t do the same – read on.

Incorrect method #2 – not catching the first call

To fix the problem above, you need to use the delegate notation at the start as well:

this.$scope.$watch(() => this.$scope.person.firstName, () => {
    alert("person.firstName changed to " +
        this.$scope.person.firstName);
});

See the difference? As you now type a “J” in the top text box, you immediately get a “person.firstName changed to J” alert. Making it almost impossible to type. But you get the drift.

But then we arrive at the next problem – this is still not correct: it goes off initially, when nothing has changed yet. This is undesirable in most occasions.

The correct way

It appears the callback actually has a few overloads with a couple of parameters, of which I usually only use oldValue and newValue to detect a real change. Kinda like you do in an INotifyPropertyChanged property:

this.$scope.$watch(() => this.$scope.person.firstName, 
                         (newValue: string, oldValue: string) => {
    if (oldValue !== newValue) {
        alert("person.firstName changed to " +
            this.$scope.person.firstName);
    }
});

Now it only goes off when there’s a real change in the watched property.

…and possibly an even better way

I am not really a fan of a lambda calling a lambda in a method call, so I would most probably refactor this to

constructor(private $scope: Scope.IDemoScope) {
    if (this.$scope.person === null || this.$scope.person === undefined) {
        this.$scope.person = new Scope.Person();
    }
    this.$scope.$watch(() => this.$scope.person.firstName, 
                            (newValue: string, oldValue: string) => {
        this.tellmeItChanged(oldValue, newValue);
    });
}

private tellmeItChanged(oldValue: string, newValue: string) {
    if (oldValue !== newValue) {
        alert("person.firstName changed to " +
            this.$scope.person.firstName);
    }
}

as I think this is just a bit more readable, especially if you are going to do more complex things in the callback.

Demo solution can be found here

Update 27-02-2015: in the original post I swapped oldValue and newValue. No-one apparently even caught that, until my colleague Adrian Tudorache actually tried to follow this post. Thanks Adrian!

5 comments:

Bert Loedeman said...

Hi Joost, nice article! A little piece of extra information about the problem of 'incorrect method #1' with the watch resulting in an undefined property:

the cause of this problem is a problem with function closure, a common issue in Javascript. Most probably, the 'this' keyword you are using will be a reference to the 'Window' object.

The 'delegate notation' you are using is the lambda function notation TypeScript provides. Especially when using the lambda notation, TypeScript provides what is called 'lexical scope' (in the resulting JavaScript code you will find this to be replaced by _this ;)). Using an old school JavaScript notation in this scenario would not work, since TypeScript would not provide lexical scoping in this scenario (by design). An article on StackOverflow does clarify the behavior I mention: http://stackoverflow.com/a/16158550/702357.

Unknown said...

I think you can change the third form

From:

this.$scope.$watch(() => this.$scope.person.firstName,
(newValue: string, oldValue: string) => {
this.tellmeItChanged(oldValue, newValue);
});

To:

this.$scope.$watch(() => this.$scope.person.firstName,
this.tellmeItChanged.bind(this);
);

This is closest I could come to something not entirely unlike c#.

Joost van Schaik said...

@Resu that might well be the case. As you can see this article is already over 2 years old - so pretty much from the dark ages at Javascript speed. I will take your word for it ;)

Unknown said...

I am not able to download source code from given link.

Joost van Schaik said...

I am truly sorry @Naresh - I have moved to a different provider and all links are apparently case sensitive now. Check it here http://www.schaikweb.net/dotnetbyexample/AtsDemo2.zip