Avoid this common Angular refactoring mistake

How to preserve reference to data when factoring out model data to services.

Same list of items in the parent's controller

Imagine you have an Angular controller showing a list of items using ng-repeat directive. If there is another controller it can simply show the same list - by default the parent's scope is either reused or a prototype of the child's scope.

The HTML template is simple

1
2
3
4
5
6
7
8
9
10
11
12
<div class="parent" ng-controller="Parent">
<h2>Parent controller</h2>
<ul>
<li ng-repeat="item in items">{{ item }}</li>
</ul>
<div class="child" ng-controller="Child">
<h2>Child controller</h2>
<ul>
<li ng-repeat="item in items">{{ item }}</li>
</ul>
</div>
</div>

The application code

1
2
3
4
5
angular.module('App', [])
.controller('Parent', function ($scope) {
$scope.items = ['foo', 'bar'];
})
.controller('Child', function () {});

Adding new item to the list

We might want to edit the list of strings, for example by adding new items.

  • The entered item is added in the parent controller and the changes are reflected instantly in the child controller. The child's $scope.items is the same as the parent's $scope.items because of scope inheritance.
1
2
3
4
5
6
7
8
angular.module('App', [])
.controller('Parent', function ($scope) {
$scope.items = ['foo', 'bar'];
$scope.addItem = function (x) {
$scope.items.push(x);
};
})
.controller('Child', function () {});

Use directives instead of controllers

While preparing for the future and to better isolate the components, let us rewrite controllers to be Angular directives.

1
2
3
4
<body ng-app="App">
<h1>Scopes to directives</h1>
<parent class="parent"></parent>
</body>

The parent directive can even update the list of items periodically.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
angular.module('App', [])
.directive('parent', function () {
return {
restrict: 'E',
scope: {},
template: '<h2>Parent directive</h2>' +
'<ul>' +
'<li ng-repeat="item in items">{{ item }}</li>' +
'</ul>' +
'<child class="child"></child>',
controller: function ($scope, $interval) {
$scope.items = ['foo', 'bar'];
$interval(function () {
$scope.items.push('item ' + ($scope.items.length + 1));
}, 3000);

}
};
})
.directive('child', function () {
return {
restrict: 'E',
template: '<h2>Child directive</h2>' +
'<ul>' +
'<li ng-repeat="item in items">{{ item }}</li>' +
'</ul>'
};
});

Every 3 seconds a new item will be pushed into the array. Notice that both lists are updated.

We can confirm that the list reference is the same in both directives. From the browser console execute this code to check

1
2
3
var parentScope = angular.element($('.parent')).isolateScope();
var childScope = angular.element($('.child')).scope();
parentScope === childScope // true

Separate child's scope from the parent

Let us go one step further. We can make the child's scope isolate too, explicitly passing only the items list reference.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
angular.module('App', [])
.directive('parent', function () {
return {
restrict: 'E',
scope: {},
template: '<h2>Parent directive</h2>' +
'<ul>' +
'<li ng-repeat="item in items">{{ item }}</li>' +
'</ul>' +
'<child class="child" list="items"></child>',
controller: function ($scope, $interval) {
$scope.items = ['foo', 'bar'];

$interval(function addItem() {
// overwrite the reference
$scope.items = $scope.items.concat('item ' + ($scope.items.length + 1));
}, 3000);
}
};
})
.directive('child', function () {
return {
restrict: 'E',
scope: {
list: '='
},
template: '<h2>Child directive</h2>' +
'<ul>' +
'<li ng-repeat="item in list">{{ item }}</li>' +
'</ul>'
};
});

The list updates every 3 seconds, just like before

Notice that we now overwrite the $scope.items array (after adding new item)

1
2
3
4
function addItem() {
// overwrite the reference
$scope.items = $scope.items.concat('item ' + ($scope.items.length + 1));
}

Yet, both components stay in sync. This is because the Angular's digest cycle noticed the change and copied the new array reference from the parent to the child scope. We can confirm this but executing the following code

1
2
3
4
var parentScope = angular.element($('.parent')).isolateScope();
var childScope = angular.element($('.child')).isolateScope();
parentScope === childScope // false
parentScope.items === childScope.list; // true

Every 3 seconds, the parent's scope replaces the $scope.items with a new value. After $interval is finished, the digest cycle runs and synchronizes the properties mapped from the parent's scope to the child's scope. We don't have to worry!

Move data to a service

We have refactored the controllers into directives. The next logical step is to move data from controllers into a separate service instance.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
angular.module('App', [])
.service('Items', function ($interval) {
var items = ['foo', 'bar'];
$interval(function () {
// overwrite the reference
items = items.concat('item ' + (items.length + 1));
console.log('items', items);
}, 3000);
return {
all: items
};
})
.directive('parent', function () {
return {
restrict: 'E',
scope: {},
template: '<h2>Parent directive</h2>' +
'<ul>' +
'<li ng-repeat="item in items">{{ item }}</li>' +
'</ul>' +
'<child class="child" list="items"></child>',
controller: function ($scope, Items) {
$scope.items = Items.all;
}
};
})
.directive('child', function () {
return {
restrict: 'E',
scope: {
list: '='
},
template: '<h2>Child directive</h2>' +
'<ul>' +
'<li ng-repeat="item in list">{{ item }}</li>' +
'</ul>'
};
});

But this stopped working. The console clearly shows a growing list of items, but the changes are not reflected in the dome

Why? Well, the parent controller saved the first list reference as the local variable

$scope.items = Items.all;

After 3 seconds passed, inside the Items service the reference was set to point at the new list. But there Angular digest cycle only keeps scope properties synchronized, not services and scopes. The parent and child directives never found out about the new reference.

1
2
3
4
5
$interval(function () {
// overwrite the reference
items = items.concat('item ' + (items.length + 1));
console.log('items', items);
}, 3000);

It is simple to break the connection like this when refactoring the Angular code, because the digest cycle runs silently behind the scenes. Once you start refactoring, and move things away from scopes, it is easy to suddenly find yourself with a stopped data flow.

Solutions

There are several solutions for preserving the link between the Model (data in the service) and the Controller (the scope object inside the directives).

Do not store the reference at all

The first solution is to NOT store the array reference on the scope, calling the Items.all() method every time we need the list. Here is the relevant code changes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// the service returns a method instead of the reference
return {
all: function () { return items; }
};
// the directives use the method in the template
template: '<h2>Parent directive</h2>' +
'<ul>' +
'<li ng-repeat="item in items.all()">{{ item }}</li>' +
'</ul>' +
'<child class="child" list="items"></child>',
// the controller stores service reference on the scope
// to allow template access
controller: function ($scope, Items) {
$scope.items = Items;
}

The solution works and goes well a functional programming principle: pass around behavior (functions), rather than plain data.

Never overwrite the reference in the service

We can still return the list reference, but be vigilant inside the service to NOT overwrite it when a new list is computed. Let us go back to the plain item in items inside the directive

1
2
3
4
<h2>Parent controller</h2>
<ul>
<li ng-repeat="item in items">{{ item }}</li>
</ul>

The service fills an existing array instead of overwriting the variable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fill(destination, source) {
for (var k = 0; k < source.length; k += 1) {
destination[k] = source[k];
}
destination.length = source.length
}
angular.module('App', [])
.service('Items', function ($interval) {
var items = ['foo', 'bar'];
$interval(function () {
var newItems = items.concat('item ' + (items.length + 1));
fill(items, newItems);
}, 3000);
return {
all: items
};
})
.controller('Parent', function (Items, $scope) {
$scope.items = Items.all;
$scope.addItem = function (x) {
$scope.items.push(x);
};
})
.controller('Child', function () {});

The fill(destination, source) function is used to copy items from the new array to the existing ones, guaranteeing that the returned array is still valid.

Because the original reference never changes and the fill takes the destination Array as its first argument, we can apply partial binding to create a convenient method for filling the array and never accidentally overwriting it

1
2
3
4
5
6
7
8
9
10
11
.service('Items', function ($interval) {
var items = ['foo', 'bar'];
var setItems = fill.bind(null, items);
$interval(function () {
var newItems = items.concat('item ' + (items.length + 1));
setItems(newItems);
}, 3000);
return {
all: items
};
})

Conclusion

When you are refactoring Angular directives, remember that the properties are synchronized during the digest cycle. Just moving the code into services might break this link: the updated values in the services will not be reflected in the directives, leading to an interesting debug experience. If you return a reference to an Array from a service, assume that you need to always place new values into that Array and not create a new one.