Understanding How To Use $scope.$watch() With Controller-As In AngularJS
Right now, in the AngularJS community, the "controller as" syntax is becoming very popular because it disambiguates view-model references within the view. However, with the use of the controller-as syntax, people are also using the "vm" notation within their controllers. This is totally fine; but, it's important to understand why this works and what implications it has for Controller-View coupling and, especially, for $scope.$watch() expressions within your controller and your directive linking functions.
Run this demo in my JavaScript Demos project on GitHub.
First, let's get rid of some of the magic. The "controller as" syntax isn't doing anything all that complex. It's literally creating a scope-based reference to the Controller instance. In fact, you can easily re-create the controller-as functionality in earlier versions of AngularJS by using a custom directive.
Once you understand how the controller instance is wired into the existing scope, you can start to think about how you want to monitor dirty-data with scope.$watch() expressions. If you follow John Pappa's style guide, the convention is to watch "vm.*" expressions. However, this only works if you use "vm" both internally and externally to the controller. This is not a bad thing; but, it is definitely creating coupling of your Controller to your View since your View now dictates where your Controller has to look for data.
If you don't want to couple your Controller to your View (in this way), you can watch functions using scope.$watch(). This allows the controller to define exactly what data is be observed and where that data is being referenced. And, in reality, this is actually more efficient because is saves AngularJS the overhead of having to parse the expression. The downside to this, of course, is that this approach is more verbose. That said, verbosity can be easy encapsulated behind functions in order to maintain readability.
To see the various approaches side-by-side, I have a demo here in which I increment a view-model value, "fooCount". As we increment the value, we're also going to be watching it with three different $scope.$watch() expressions. The first one demonstrates that that the "vm.*" only works by convention; the second one demonstrates why the "vm.*" approach works when it does work; and, the third one demonstrates how to watch any value without coupling the Controller to the View.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Understanding How To Use $scope.$watch() With Controller-As In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
Understanding How To Use $scope.$watch() With Controller-As In AngularJS
</h1>
<!--
Notice that we are aliasing the view-model as "appController". Behind
the scenes, this is doing nothing more than setting up the following
scope-level binding:
$scope.appController = {{ controller-instance }}
-->
<div ng-controller="AppController as appController">
<p>
<a ng-click="appController.incrementFoo()">Increment Foo</a>:
{{ appController.fooCount }}.
</p>
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.16.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root for the application.
angular.module( "Demo" ).controller(
"AppController",
function( $scope ) {
// Setup our Controller instance as the view-model.
var vm = this;
// This is the value we will be "watching".
vm.fooCount = 0;
// When you watch a "vm.*" value from within the Controller, you are
// making the assumption (ie, creating coupling) that your View is using
// the same variable - "vm" - externally that you are using internally.
// --
// CAUTION: In our case, this is NOT true - the view is using the
// "appController" alias.
$scope.$watch(
"vm.fooCount",
function handleFooChange( newValue, oldValue ) {
console.log( "vm.fooCount:", newValue );
}
);
// If your Controller and your View are not using the same view-model
// alias, then you can explicitly watch the value that your View is using.
// Here, we're watching "appController" instead of "vm". This might make
// you dry-heave a tiny bit; but, this is essentially what you are doing
// when you use "vm.*" as well.
$scope.$watch(
"appController.fooCount",
function handleFooChange( newValue, oldValue ) {
console.log( "appController.fooCount:", newValue );
}
);
// To better decouple your Controller from your View, you can define a
// watch function instead of providing a string-based watch expression.
// --
// NOTE: Behind the scenes, this is what the $parse() service is doing
// anyway; so, don't think of this as more work. In reality, it's actually
// less work for AngularJS since it doesn't have to parse the expression
// into a function.
$scope.$watch(
function watchFoo( scope ) {
// Return the "result" of the watch expression.
return( vm.fooCount );
},
function handleFooChange( newValue, oldValue ) {
console.log( "fn( vm.fooCount ):", newValue );
}
);
// Expose public API.
vm.incrementFoo = incrementFoo;
// ---
// PUBLIC METHODS.
// ---
// Increment the value that we will be watching.
function incrementFoo() {
console.log( "---->", ++vm.fooCount, "<----" );
}
}
);
</script>
</body>
</html>
When we run this page and increment the value a few times, we get the following page output:
vm.fooCount: undefined
appController.fooCount: 0
fn( vm.fooCount ): 0
----> 1 <----
appController.fooCount: 1
fn( vm.fooCount ): 1
----> 2 <----
appController.fooCount: 2
fn( vm.fooCount ): 2
----> 3 <----
appController.fooCount: 3
fn( vm.fooCount ): 3
Notice that "vm.fooCount" never watched the value we were incrementing. This is because watching "vm.*" only works when both the Controller and the View follow the same "controller as" convention (which my demo is not doing).
Watching "appController.fooCount" works because the Controller is using the same alias value that the View is using. Of course, this lets the View dictate the workflow, which reverses the normal flow of dependency.
NOTE: This is the same thing that "vm" does as well; it's just that you probably never stopped to think about it.
And finally, watching a function reference both works and removes this coupling of the Controller to the View. Of course, the downside is the additional complexity and verbosity. That said, watching a function can be incredibly powerful - just look at the "watchDynamicValues" function in my Absolute Grid exploration.
I am not advocating against the use of the "vm.*" convention. I am only trying to clarify how it works; and, that it only works when the Controller is coupled to the View (as opposed to the View being coupled to the Controller). Conventions, after all, are often about these kinds of trade-offs.
Want to use code from this post? Check out the license.
Reader Comments
I'm so used to seeing anonymous functions being passed into $scope.$watch I had to do a double take to see what was going on. Thanks for the article, I always learn something new and interesting.
@Keith,
Glad you found it useful. As far as anonymous functions vs. named functions, the naming can help with stack-traces if an error is produced. Plus, I like the way it looks - I feel like it self-documents a bit more :)
Ben,
I'm curious what scenarios you run into that force you to use $watch on your controller. I've been using controller-as since it became available, and I have never once had to restore to a $scope.$watch in a controller (with the exception of "controllers" in directives due to isolate scope). For example, let's say I have a computed function that depends on properties "a" and "b", then I simply expose the computation and the digest loop takes care of re-evaluating - i.e. I can think of my controller as a state machine, encapsulate states in properties, and I don't need to watch.
I've even found if I'm maintaining state in a service, I just need to expose the service value and again I don't have to $watch it.
I do know there is probably an edge case there that requires an explicit $watch, but I haven't found yet and therefore am curious. Interested to see your response!
Thanks,
Jeremy
@Jeremy,
Excellent question! In many cases, moving to a $watch() is just a matter of a performance negotiation and flexibility. For example, imagine that I have a collection of items and I need to filter the collection base on a form-input that is powered by ngModel. There are [at least] two ways to go about this. The first would be to let the DOM really drive that process so that the ngRepeat (for the collection) might look something like this:
<li
ng-repeat="item in vm.items"
ng-show="vm.matchesFilter( item, vm.form.filter )"> ... </li>
Here, the ngShow expression (which is a call to matchesFilter()) is being called for each item that stamped out in the collection. And, as long as that returns True/False, then the page will work as intended.
But, what happens if I want to tell the user how many items are being filtered? Such as "Showing 3 of 15". Now, I have to calculate that "3" somehow. You could have, of course, have some sort of method that looks like:
Showing {{ vm.getHiddenCount( vm.items ) }} of {{ vm.items.length }}
But now, I have to re-scan that entire collection on every digest, looking for hidden values. On it's own, this isn't a problem. But, as you get more and more bindings, this kind of work can become slow. And, moving some of this into a single $watch() that can change the internal state of the view-model can help make that faster.
And then, there are things that require AJAX. Imagine you have a search field with an auto-suggest that requires you hit the server to get matches. You wouldn't want to let the DOM drive that interaction as it would be making more AJAX requests that necessary (most likely). You'd probably want to put that inside a $watch() in the controller that implements some sort of debouncing that only hits the server as needed.
Long story short, there's *nothing* implicitly wrong with letting the DOM bindings execute recalculations. But, as your AngularJS applications get bigger, moving things to $watch() bindings... or even better, acute events (such as an on-change event) ... can provide a performance boost.
@Ben,
That's a great explanation! I'm still curious about the use of $watch in that case. For example, in the case of showing the count, what are your thoughts on simply exposing a property:
Object.defineProperty(controller.prototype, "objCount", {
enumerable: true,
configurable: false,
get: function () {
// count logic here;
}
};
Then you just bind to the property. You don't have to worry about the $watch because any time the values on the model become dirty, the property will be re-evaluated. Even if you're concerned about multiple hits, you can always just expose the property objCount, use a property for the variables that are important and have them update the obj count when they mutate.
As for the auto-suggest, what about simply binding the search filter directly and using ng-model-options { debounce } to handle the throttling?
Thanks Ben! I always learn a ton when I read your blog, and appreciate your deep dives into the areas you explore.
Jeremy
@Jeremy,
The native AngularJS "debounce" concept is very interesting. And, to be honest, much of my AngularJS code is still in ... wait for it... AngularJS 1.0.8 :( As such, I haven't been able to get much "field experience" with some of the most recently-added features of Angular. Though, I definitely try to dig into it when I can. As such, I can't say one way or another for sure. Definitely an interesting idea, though, to use that to manage an AJAX request.
I also lack much of any experience with the Object.define stuff. I've read about it, but never really used it myself. Something about it doesn't quite connect with me emotionally. I think the AngularJS team was looking at using that kind of stuff to power of the internal dirty-data checking... but I feel like I read somewhere that they abandoned the idea... not sure.
Sorry that my follow answers are much less interesting :D
Amazing article thanks for your time and efforts
@Ahmed,
Thank you very much :D
hi again, thax for article.
but if you followed John Papa guide, you should be use cntrler as syntax like :))
/* avoid */
function Customer($scope) {
$scope.name = {};
$scope.sendMessage = function() { };
}
/* recommended - but see next section */
function Customer() {
this.name = {};
this.sendMessage = function() { };
}
so, how we can do it if we use like that?
I have to post spam, i have to stay off topic, i have to ask unrelated question to your related question...
Why da deuce do you have a picture with some dude in every single post?
Don't you have a facebook account or something. :]
I like your approach to decouple the Controller from the View. In this way I can use the vm convention in the controller and a more descriptive name in the View.
Thanks Ben.
After executing Line 54 of the code above, where exactly (i.e., in which scope) is vm created? [It is not in $scope or $rootScope. I checked.]
Thanks.
More questions.
1. Line 109 exposes incrementFoo as a method on vm. Line 28 passes control to appController.incrementFoo(). How do things work correctly? Even though vm and appController are different objects do they have the same value both being instances of the controller?
2. When I click on Increment Foo, incrementFoo executes and increments vm.fooCount. This causes both watches to execute. Have I changed anything in $scope? How is the reference to scope on Line 95 resolved?
3. What is the purpose of Line 98
Thanks.
Great article. Helped me.
Thanks for this. I ran into this problem after implementing John Pappa's style guide and couldn't figure out why my $watch suddenly stopped working (I don't use 'vm' in the view).
Thanks Ben! Can I download your knowledge into my brain when the technology becomes available? LOL.
Great Info :D
Usually we cannot forget the $scope dependency, as an usefull helper function container.
thanks.:).it really helped me a lot..
thanks for the effort! I learned a lot
When attempting to to achieve this inside a directive, make sure to set bindToController: true on the directive to access the scope inside the controller's view model
Thanks! Your posts are always useful and helpful Uncle Ben! ;) Keep at it.
Thanks!! it's really frustrating that almost all of the documents and tutorials still based on the old $scope way of doing things, you just saved me a few hours of investigating , please keep up the good work :)
Thanks, nice article
Hey man! Just saved my life! Thx for the lesson!
@All,
Glad people are still finding value in this !
So cool to read your note about "Behind the scenes..." when using a watch function instead of string-based watch expression. It seems that you sort of read the minds of developers beforehand. I was actually hesitant to use the watch function because I guessed it would be more work for angular to watch it. Thanks for clearing that out.