Watch, watchGroup, watchCollection and Deep Watching in AngularJS 1.x

Published Oct 25, 2017Last updated Apr 23, 2018
Watch, watchGroup, watchCollection and Deep Watching in AngularJS 1.x

My original post can be found here.

The Many Layers of Change Detection

A user clicks the “upvote” button,

1*kU6Q7pi0coovXxIOy8UaEw.png

attempting to increase the vote count to 1034. An action $apply’ed through an ng-click

<i class="icon upvote" ng-click="vm.user.upvote(vm.answer)"></i>

would trigger a $digest cycle under the hood, which checks all registered $watch’es for changes. Then, an expression providing “voted” styling for answers, registered through ng-class

<i class="icon upvote"
  ng-click="vm.user.upvote(vm.answer)"
  ng-class="{voted: vm.answer.isVotedFor(vm.user)}">
</i>

and an expression providing the aggregate number of votes, registered through double curly braces {{}}

<i class="icon upvote"
  ng-click="vm.user.upvote(vm.answer)"
  ng-class="{voted: vm.answer.isVotedFor(vm.user)}">
</i>
 {{ vm.answer.voteCount() }}
<i class="icon downvote"
  ng-click="vm.user.downvote(vm.answer)"
  ng-class="{voted: vm.answer.isVotedAgainst(vm.user)}">
</i>

would trigger the appropriate DOM updates:

1*CxheOXIunYTXze80R08l5A.png

Change detection is a fundamental part of AngularJS applications. Whether utilizing native AngularJS directives or building your own, an understanding of the many layers of data watching is essential to understanding Angular.


Some Interesting (but skippable) background

An angular expression can produce different values over time. Watching consists of getting the value of this expression and performing a task when it has changed. And in angular, an expression can take the form of a string or a javascript function:

  1. any ol’ javascript function
scope.greeting = 'hi there';

var getGreeting = function(scope) {
  return scope.greeting;
};
scope.$watch(getGreeting, function(greeting) {
  console.log('greeting: ', greeting); // greeting: hi there
});
  1. string
scope.greeting = 'hi there';

scope.$watch('greeting', function(greeting) {
  console.log('greeting: ', greeting); // greeting: hi there
});

Function or… string? Internally, the first thing the $scope.$watch method does is pass its first parameter to the $parse service. The $parse service is a function that returns another function (let’s call it the “parseFn”). The parseFn expects to be called with the desired context object against which the original string or function may be evaluated e.g.

var context = {first: 'steph', last: 'curry'};

// with string
var string = 'last + ", " + first';
var parseFn = $parse(string);
parseFn(context); // => curry, steph

// with function
var func = function(context) {
  return context.last + ', ' + context.first;
};
var parseFn = $parse(func);
parseFn(context); // => curry, steph

If the $parse service receives a string, work needs to be performed in order to translate the string into proper javascript that is capable of evaluation against an object i.e. a function. On the other hand, if it receives a function, well… not much needs to happen. Since the service has received what it must produce, it may assume the function has been properly prepared. As you can see, most of the angular “magic” lies in parsing the string parameter into such a function to enable the intake of a string.

At any point in time. There is a reason angular has chosen the function-with-context-object-parameter structure: calls to the function reveal changes to underlying context, which means that the function can be invoked at any point in time to retrieve the value of the underlying expression or function at such time:

var fullName = 'last + ", " + first';
var getFullName = $parse(fullName); // getFullName is the parseFn!

var context = {first: 'steph', last: 'curry'};
getFullName(context); // => curry, steph
context = {first: 'russ', last: 'westbrook'};
getFullName(context); // => westbrook, russ

Since scope objects are…objects, this of course works with scope objects as well. The string ‘vm.answer.isVotedFor(vm.user)’ from the “upvote” example above — after being passed to parse by watch and transformed into a function that is ready to accept an object — was ready to reveal any new isVotedFor status reflected in an updated scope object. Now the function-returning-a-function structure of the parse service also begins to make sense. The returned function (i.e the parseFn), prepped and ready to accept the latest scope object, is exactly what the digest cycle needs and precisely what it receives in order to get the latest value of an expression. Sensibly, the parseFn is named “get” internally because of these getter qualities.

Against the last cached value. Now to determine change, all the $digest cycle has left to do is cache the last value retrieved by the registered parseFn and compare it to the next….which brings us to the topic of this post: this comparison between new and old values, what constitutes equality and the multiple levels of change detection in angular.


$watch — type 1

The simplest and most common, this type of watch uses javascript’s === operator to compare old and new values, invoking the callback only upon strict inequality:

scope.$watch('greeting', function(greeting) {
  console.log('greeting: ', greeting);
});

scope.greeting = 'hi there'; // greeting: hi there

$timeout(function() { scope.greeting += ', Joe'}); // greeting: hi there, Joe

For expressions that evaluate to primitive types like Strings and Numbers, this has a predictable effect. Old and new values of 4 and 4 are considered equal, but 4 and 5 and 4 and ‘4’ are not. Similarly, ‘hello’ and ‘hello’ will not register a change, but ‘hello’ and ‘goodbye’ will. In the above “upvote” example, angular’s curly brackets {{}} registered this type of watch for the expression vm.answer.voteCount(). When 1034 was compared against 1033, a change was registered.

For expressions that evaluate to javascript objects, strict equality means something less intuitive. The same object, no matter how mutated between equality checks, will not register “change”, whereas, two different objects, no matter how similar, will:

scope.$watch('obj1', function callback(newValue) {
  console.log('obj1: ', newValue);
});

// (when initialized) prints out: undefined

$timeout(function() {
  scope.obj1 = {name: 'jonathan'}; // prints out: {name: 'jonathan'}
});

$timeout(function() {
  scope.obj1.name = 'michael'; // no "change" registered, no print out
}, 500);

$timeout(function() {
  scope.obj1.age = 30; // no "change" registered, no print out
}, 1000);

$timeout(function() {
  // it's a new object! 
  scope.obj1 = {name: 'jonathan'}; // prints out: {name: 'jonathan'}
}, 1500);

Advantages and limitations of watchThe important thing to note in determining how change is registered is not whether two objects have the same properties but whether two objects refer to the same address in memory i.e whether they are the same object. Notice in the above example that different objects with exactly the same properties and values nevertheless register change and print to the console with the latest object because they are different objects. Whereas, the same object reference prints nothing to the console no matter how mutated between equality checks.

$watchGroup — type 2

This type of watch can handle multiple expressions instead of being limited to just one, invoking the registered callback when any of the expressions have changed.

scope.$watchGroup(['expression1', 'expression2'], function(arrayOfExpressions) {
  ...
});

You may want to use this type of watch if two or more properties are coupled. In the above “upvote” example, you could use this watch to disable further voting if an answer has been voted for or against:

var isVotedGroup = [
  'vm.answer.isVotedAgainst(vm.user)',
  'vm.answer.isVotedFor(vm.user)'
];

var unwatch = scope.$watchGroup(isVotedGroup, function(isVotedGroup) {
  var hasVoted = isVotedGroup.some(function(bool) {
    return bool;
  });
  if (hasVoted) {
    vm.answer.disableVoting(vm.user);
    unwatch();
  } 
});

The important thing to note is that the containing array itself is not watched by watchGroup, in stark contrast to (maybe the more familiar) watchCollection; rather, each member of the array is. Much like the subject of a regular type 1 watch, each member of the array can itself be a string expression capable of contextual evaluation or a function ready to be passed a context object. As a result, watchGroup can be seen as simply a way to group together multiple regular type 1 watch’es with a single callback function. When any of the grouped watches registers a change, the callback is invoked. This set up has the benefit of allowing expressions to be members of the group as described. But members of the group cannot be dynamically added or subtracted and order is not an applicable concept…which brings us to watchCollection.

$watchCollection — type 3

This type of watch is intended for javascript arrays, invoking the callback when collection level changes have occurred.

$scope.firstTeam = [{
  first: 'steph',
  last: 'curry'
}, {
  first: 'lebron',
  last: 'james'
}];
$scope.$watchCollection('firstTeam', function(firstTeamArray) {
  console.log("change to the first team! ", JSON.stringify(firstTeamArray));
});

$timeout(function() {
  $scope.firstTeam.push({first: 'russel', last: 'westbrook'});
}); // new member, print!

$timeout(function() {
  $scope.firstTeam.sort(function(player1, player2) {
    if (player1.first < player2.first) return -1;
  });
}, 300); // reordering, print!

$timeout(function() {
  $scope.firstTeam[0].height = 75;
}, 600); // member mutation, no print

Advantages and limitations of watchCollectionWhere watchGroup allows multiple expressions capable of contextual evaluation to point to one callback, watchCollection allows only one — in the above example, ‘firstTeam’. On the other hand, the type of watch applied to such expression is more involved. Type 1 watchers care only about addresses for objects and values for primitives. As objects themselves, arrays are no different. Subject to a type 1 watch, expressions that evaluate to an array will not register change no matter how mutated between checks until switched out for an entirely new array and address in memory. watchCollection is vastly different. Mutation is the name of the game. In particular, watchCollection keeps its eye on two types of surface level array mutations: (1) addition/subtraction and (2) reordering. Adding or subtracting members of the array and reordering members of the array result in change being recognized and the registered callback being invoked. watchCollection only stops short of watching below-surface mutation i.e. deep mutation, mutations to the members themselves… which brings us to deep watching.

$watch(…, …, true) — type 4

The most expensive watch, this type of watch is also the most thorough, recursively scaling the depths of any complex object and invoking the registered callback when any branch of the tree has undergone change. Note the true flag, which tells angular to perform this type of watch instead of a type 1:

scope.tree = {
  type: 'oak',
  branches: [{
    color: 'brown',
    length: '11',
    leaves: [{
      color: 'green',
      shape: 'oval',
    }, {
      color: 'brown',
      shape: 'leafy'
    }]
  }]
};
scope.$watch('tree', function(tree) {
  console.log('change to tree!', JSON.stringfiy(tree));
}, true);

$timeout(function() { scope.tree.type = 'sequoia'; }); // mutation, print!

$timeout(function() {
  scope.tree.branches[0].length = 12; // mutation, print!
}, 100);

$timeout(function() {
  scope.tree.branches[0].leaves[0].color = 'clementine'; // mutation, print!
}, 200);

$timeout(function() {
  scope.tree.branches[0].leaves.push({ // mutation, print!
    color: 'rosemary'
  });
}, 300);

$timeout(function() {
  scope.tree = 'hello!'; // mutation, print!
}, 400);

watch(…, …, true) has no limitationsDeep watching has no limitations, other than performance of course — the last thing your application needs is recursive journeys into the depths of complex objects every digest cycle. On the other hand, deep watching can keep any complex data in sync with the view and, as a result, may be the easiest to understand: all change is change.


This has been a discussion of the many layers of data watching in AngularJS. I hope it helps inform your choice among them!

Discover and read more posts from Jonathan Milgrom
get started