Keeping Angular "service" list data in sync with controllers
Learning Angular has been one of the greatest productivity boosts for rapid application development in my career. However, some of the common strategies implemented can be improved in my opinion.
In AngularJS, there is a great deal of importance placed on separation of concerns. One of the most practiced patterns for holding application state is move this data into angular services or factories. Which one of the former to use is totally a personal preference in my opinion (I opt for factories most of the time).
The purpose of this post is aimed at services that hold collections that update from remote data calls.
Targeted AngularJS version at time of writing: 1.2.19
Too many times, I have run across angular controllers bringing in the $scope
service just to $watch
a service collection:
The bad:
app.controller('Ctrl1', function($scope, DataFactory) {
// bind the controller property to the service collection
this.items = DataFactory.items;
// watch the collection for changes
$scope.$watch(watchSource, function(current, previous){
this.items = current;
});
function watchSource(){
return DataFactory.items;
}
});
app.factory('DataFactory', function($q, $timeout) {
var svc = {};
svc.items = [];
svc.getDataStream = function() {
var fakeData = [
{ id: 1, name: 'name 1' },
{ id: 2, name: 'name 2' },
{ id: 4, name: 'name 4' }
];
// using $q to fake async data grab
return $q.when(fakeData)
.then(function(data) {
svc.items = data;
});
};
return svc;
});
I know what you are thinking “why doesn’t this just work by default?” We are clearly updating the DataFactory.items
.
svc.getDataStream = function() {
return $q.when(fakeData)
.then(function(data) {
// here we are clearly reseting the data
// to the response of the call.. Why doesn't
// it just databind?
svc.items = data;
});
};
The reason that angular does not “watch” this value is because when it set’s up the implicit $watch
from a view, it references the original array from the DataFactory
. But when the data comes back through $http
, it replaces the property with a different array reference. Thus Angular can’t watch the collection without adding nasty $watch
functions in your controllers.
So why is the $watch
a bad thing in the controller?
It adds to the cognitive load needed to understand what is going on in the controller. When we look at code that others (or ourselves 6 months from now) wrote, being able to easily understand the what without spending a lot of time parsing the how is very beneficial. Any bindings in your view cause implicit watches to be set. Also, all $watch
functions are executed for every $digest
which may occur many times in a “digest cycle” (angular kicks these off with most interactions). Adding yet another watch is a pattern that may get you into performance issues in the future.
Surely we can remove the dependency on $scope
just to watch this collection. Let us look at a very simple example of this separation adapted from Todd Motto.
The better:
app.controller('Ctrl1', function(DataFactory) {
// bind the controller property to the service collection
this.items = DataFactory.items;
// invoke the call to get data
DataFactory
.getDataStream()
.then(function() {
// update the controller collection property
this.items = DataFactory.items;
}.bind(this));
});
// sample "service" for getting data
app.factory('DataFactory', function($q, $timeout) {
var svc = {};
svc.items = [];
svc.getDataStream = function() {
var fakeData = [
{ id: 1, name: 'name 1' },
{ id: 2, name: 'name 2' },
{ id: 4, name: 'name 4' }
];
// using $q to fake async data grab
return $q.when(fakeData)
.then(function(data) {
svc.items = data;
});
};
return svc;
});
Now I’m not hating on this format. Here after every call to update the service data source, both the source and controller reference get updated. It works for what it is intended for and it is fairly easy to understand what is going on. However, it does not really address controllers other than the one calling the service updated being notified.
How about this:
app.controller('Ctrl1', function(DataFactory) {
// bind the controller property to the service collection
this.items = DataFactory.items;
// but wouldn't it be so much better
// to just call it and let it work
DataFactory.getDataStream();
});
Doesn’t this just read so much cleaner?
Enter angular.copy
. What angular.copy
does when given a new array and a source array is empty the source (by setting length to 0
i think..) and then repopulate the array with the new array items.
Our end result:
var app = angular.module('app', []);
app.controller('Ctrl1', function(DataFactory) {
this.items = DataFactory.items;
DataFactory.getDataStream();
});
app.controller('Ctrl2', function($timeout, DataFactory) {
// when this eventually fires and gets *remote* data again
// our other controller will automatically sync up
// without the need for the $watch function
$timeout(DataFactory.getDataStream, 2000);
});
app.factory('DataFactory', function($q) {
var svc = {};
svc.items = [];
svc.getDataStream = function() {
var fakeData = [
{ id: 1, name: 'name 1' },
{ id: 2, name: 'name 2' },
{ id: 4, name: 'name 4' }
];
// using $q to fake async data grab
return $q.when(fakeData)
.then(function(data) {
// this is the magic
angular.copy(data, svc.items);
});
};
return svc;
});
This article was originally posted on my blog.
<3 this is great thank you!
this was great, thanks! very simple, but precisely what i wanted to do.
Angular.copy is supposed to do shallow copy. So this technique will break when data returned from endpoint is nested objects. In that case Angular.extend will work correctly. Since extend is deep copy its might be little bit slower than copy though.
Are you sure about that? It says here that angular.copy does in fact do a deep copy. https://docs.angularjs.org/…