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!

6 thoughts on “Deep-Watching Circular Data Structures in Angular

  1. Having implemented a tree structure recently, I’m curious what you needed to watch a node for? And why did you only need to watch a specific node?

    Cheers!

  2. 1. You don’t need a function to return `$scope.node.value`. Just use `$scope.$watch(‘node.value’, …)`. 2. There methods are highly inefficient (e.g. traversing the array of keys 3 times and creating a new object for EVERY $digest loop ? Just saying…

  3. Another solution is to assign the parent reference wrapped in a function.
    Instead of doing: node.parent = parent;
    You can do: node.getParent = function(){ return parent; }
    It is not as pretty when assigning the value, but is more efficient after that during the digest loops.

  4. Is there are watchTree? That would almost be a little boring! Otherwise you can use the same technique, provide your own function but instead of just watching the value, put a function which checks all children (and children of children…). You can Google tree traversal if your not familiar with how to do that.

    However, if you happen to have a large tree, you might get some performance problems. Then you can sacrifice some beauty for performance. Instead of having the nice and clean tree structure like damienklinnert (the author of the post) let the root have a dirty flag and put a reference to the root in every other node. Also provide an update function through which nodes values are updated, which sets the root’s dirty flag to true in addition to updating the node’s value. This makes it possible to notice changes quickly (in constant time). You can probably make it look fancy (from the outside) by creating a “class”/object/function…

    A simple implementation of the idea (with the gigantic tree):
    http://pastebin.com/50qPSXed

    And thanks for the post damienklinnert 🙂

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s