Deep-Watching Circular Data Structures in Angular

Getting angular’s watches right can be quite a challenge. There are several ways to watch values from a controller, be it $watch, $watchCollection or $watchGroup. Luckily there’s this amazing article of different ways to watch data that will solve most problems you might encounter.

However, most doesn’t mean all: when creating the new admin screens for Small Improvements, it took us some time to find a solution for watching a tree structure that contains nodes with cyclic references.

The Challenge

Let’s say each tree node has parent and children references attached to it (to make traversing the tree as simple as possible). Each node will also contain a value property that links to the actual data. Thus, each node might look similar to this:

$scope.node = {
  value: { /* data */ },
  parent: { /* another node */ },
  children: [ /* list of more nodes */ ]
};

How would you watch this data structure from your controller, so that changes to the node value will call a function?

Attempt 1/3: Not even close!

You might immediately answer: “just watch it … like this”:

$scope.$watch('node', function () {
  // do something
});

At the first glance this code looks simple; and it doesn’t yield any error either. Unfortunately the watcher will not get triggered when a nested property from within the value object is changed.

This is because $watch will only shallow check the referenced value by default.

Attempt 2/3: Slightly better?

“I know that problem!” you might respond. “Just add true as the the third parameter in the $watch call to deep-watch the value”.

$scope.$watch('node', function () {
  // do something
}, true);

That would work – if it wasn’t throwing an exception:

RangeError: Maximum call stack size exceeded

When deep-watching an object, angular will follow all references to other objects. Because of the parent and children attributes, this results in an infinite loop. The result is that lovely exception.

Attempt 3/3: The solution.

“I need a way to only watch the value property of each node” you will correctly recognize. Here’s how to do that properly:

$scope.$watch(watchNode, function () {
  // do something
}, true);

function watchNode() {
  return $scope.node.value;
}

According to the docs, instead of passing an expression, you can also pass a function to $watch. This function is called in each $digest cycle and it’s return value will be compared to the previous one.

Done! This is how to deep-watch a circular data structure in angular.

Bonus 1/3: What about multiple properties on each node?

In the given example, there’s only one property per node that needs to be watched. Here’s how you’d deal with multiple properties:

$scope.complexNode = {
  even: { /* data */ },
  more: { /* data */ },
  properties: { /* data */ },
  parent: { /* another node */ },
  children: [ /* list of more nodes */ ]
};

$scope.$watch(watchComplexNode, function () {
  // do something
}, true);

function watchComplexNode() {
  return without($scope.complexNode, ['parent', 'children']);
}

function without(obj, keys) {
  return Object.keys(obj).filter(function (key) {
    return keys.indexOf(key) === -1;
  }).reduce(function (result, key) {
    result[key] = obj[key];
    return result;
  }, {});
}

Bonus 2/3: What about lists of nodes?

In this case, use a map function:

$scope.$watch(watchNodeList, function () {
  // do something
}, true);

function watchNodeList() {
  return $scope.nodeList.map(nodeValue);
}

function nodeValue (node) {
  return node.value;
}

Bonus 3/3: What about performance?

These $watch functions will be executed at least once per $digest cycle, so be sure to not do any heavy computation.

 

Can you do better?

Do you enjoy tinkering with AngularJS? Do you have even better ideas on performance tuning? We’re hiring AngularJS developers in Berlin. Drop by for a coffee to learn more!