Passing State via Services
AngularJS - Communicating Between Controllers - Part 4
This is the 4th and final discussion of Communicating Between Controllers in AngularJS. In the previous three posts, I discussed using nested state, PubSub, and the router as mediums for passing state between controllers. In this post, I will discuss perhaps the most obvious (but least thought of in Angular); using an independent service (JavaScript object) to maintain state for controllers.
Why is the approach both obvious and non-obvious at the same time? Probably because its an approach that has less to do with AngularJS framework and more to do with traditional programming. Therefore, is less likely that you will see documentation on the AngularJS website despite the fact that this approach is both reasonable and encouraged.
Angular Services.
So what do I mean by using a service to pass state? It's actually far easier than you think. Imagine an object (singleton) that two or more controllers have access to (injected via AngularJS). If that object maintained it's own state, any caller against methods defined on that object could potentially access or mutate the object's state. And that's it. Really. Controllers indirectly communicate or mutate state through a shared object (i.e. service).
Here is an example service registered with Angular:
app.service("NotifyingCache", ["$rootScope", function($rootScope){
var cache = {};
this.put = function(key, value){
var oldValue = this.get(key);
cache[key] = value;
$rootScope.$broadcast(
"cache.item.updated",
{ key: key, newValue: value, oldValue: oldValue });
};
this.remove = function(key){
var value = this.get(key);
delete cache[key];
$rootScope.$broadcast(
"cache.item.removed", { key: key, value: value });
}
this.get = function(key){
return cache[key] || null;
}
}]);
In Angular, services are singleton objects that encapsulate some kind of reusable behavior. Since they are singletons, they will maintain state outside of Angular's digest cycle and will not be subject to termination (garbage collection) by the framework (like controllers are).
Services can be defined in two manners: module.factory()
and module.service()
. The difference between the two is that the developer should provide to factory
an object (or function) that represents the service entity. The service
declaration, on the other hand, expects a function that services as a constructor method to an object. AngularJS will call new
on the constructor method to create the singleton upon the first inject
request.
In addition to being plain-old JavaScript functions/objects, services have the benefit of having other services injected into them. In the example above, I requested that the $rootScope
be provided to my service so I could notify listeners of changes to the cache.
Using Services within Controllers.
Once the service is registered, it's easy to use it from within a controller. Simply ask for the service to be injected:
app.controller("Ctrl1", ["$scope", "NotifyingCache",
function($scope, NotifyingCache){
$scope.add = function(){
NotifyingCache.put($scope.key, $scope.value);
};
}]);
In this controller, I've provided a method on the $scope
to place a value in the cache. Here's the view I'm using to lift the key
and value
:
<div ng-controller="Ctrl1">
<h3>Controller 1</h3>
<input type="text" ng-model="key" placeholder="Key Name" />
<input type="text" ng-model="value" placeholder="Value" />
<button type="button" ng-click="add()">Add to Cache</button>
</div>
We've seen how to add entries, let's look at an example at how a completely independent controller (peer in scope hierarchy) can retrieve the same value:
app.controller("Ctrl2", ["$scope", "NotifyingCache",
function($scope, NotifyingCache){
$scope.get = function(){
$scope.value = NotifyingCache.get($scope.key);
};
$scope.remove = function(){
$scope.value = "";
NotifyingCache.remove($scope.key);
}
}]);
We are using actions against the $scope
to trigger calls to the cache to retrieve state. There's a good reason for this which I will discuss later. In the meantime, here's the view:
<div ng-controller="Ctrl2">
<h3>Controller 2</h3>
<p>Key '{{key}}' has a value of '{{value}}'.</p>
<input type="text" ng-model="key" placeholder="Key Name" />
<button type="button" ng-click="get()">Get From Cache</button>
<button type="button" ng-click="remove()">Remove From Cache</button>
</div>
Calling $scope.get()
will retrieve a value for the specified key in the input box from the cache. This assumes that the key was added via the previous controller. Likewise, $scope.remove()
will remove keys from the cache.
Left out of the digest cycle.
I mentioned there was a reason why we were using $scope
methods to interact with the service. This is because the service's state exists outside of the AngularJS scope hierarchy. This means that changes to that state will not be observed by AngularJS during it's digest cycle.
This is probably the biggest pitfall with the service approach, but as you can see with the code above, it's not actually noticeable. The reason I say this is because most of the time, changes in state will occur because of user interaction with the controllers, which means that the digest cycle will be kicked off.
However, if you design a service that periodically makes service calls to the backend, or utilizes timeouts, this can become a problem. Let me show an example:
app.service("NotifyingCache", ["$rootScope", function($rootScope){
var cache = {};
setInterval(function(){
cache = {};
}, 10 * 1000);
// ... Imagine all the service code is still here.
}]);
Imagine we had the need to clear the cache every 10 seconds (10 * 1000
) and for some reason I wanted to go directly against the cache in one of my views:
<div>{{ cache.get('num_unicorns') }}</div>
Which would of course require my controller to set the cache on the $scope
:
$scope.cache = NotifyingCache;
The interval in NotifyingCache
is firing outside of Angular's digest cycle. This means that when the cache is cleared, there will no long be a variable called num_unicorns
. Since the digest cycle did not fire, the view with the reference to the cache variable will not update.
Notifying Angular of changes.
In the event that your service state changes outside of the digest cycle, there are a number of options for notifying Angular. All of the options, however, end up doing the same thing: calling $scope.$digest
.
Angular's digest cycle is a loop that continuously evaluates the entire or a section of the scope hierarchy for changes. The extent of the checks depends upon the scope in which $digest
was called ($digest
evaluates the current scope and all children). The digest cycle ends when the state stops changing (no more actions that cause the variables on the current scope or nested scopes to change) or when the max number of loop iterations are exceeded (10 by default).
In general, you do not call $digest
because so many other components do it for you. Services like $timeout
wrap the Javascript setTimeout()
forcing the digest cycle to be kicked off after the execution of the callback. Calls to $emit
and $broadcast
will also start the digest cycle.
If you need to manually kick of the digest cycle, it is preferred that you use the $scope.$apply()
method. Essentially, $apply
give you the ability to supply a function that will do work outside of the digest cycle and before exiting, $apply()
will call $digest()
for you:
$scope.$apply(function(){
// Make an AJAX call using jQuery
$.getJson("profiles.json", function(data){
$scope.profiles = data;
});
});
Of course, this is not a great example because Angular already gives you a great $http
component that will return a Promise
which will get resolved and kick off the digest cycle! But, I hope you get the idea.
Benefits and Pitfalls of Service Communication
Benefits
- Great decoupling. If you have a lot of shared logic or state that exists between controllers, this is a great mechanism. Honestly, this is even a great mechanism if you are making
$http
calls to the server. Hide the implementation from your controllers by using services. This leads to... - Better maintainability.
Pitfalls
- More boilerplate. In general, it will probably take more work to implement a service since you have to think about the needs of the controllers that will use them. On the other hand, this will make you think harder about you implementation and come up with better designs!.
- State synchronization with Angular. When using external mechanisms you will have to be cognizant of how the internal state of the service will be reflected in views and controllers.
Code
All of the examples in the post can be found on Github: https://github.com/rclayton/NG-Communicate-Ctrls/tree/master/service.
Stumbling my way through the great wastelands of enterprise software development.