Using The Scope Tree As A Publish And Subscribe (Pub/Sub) Mechanism In AngularJS
When I first got into AngularJS, I wanted to keep the modules of my application synchronized yet decoupled. To do this, I created a centralized "event service" that was used to trigger events in different contexts. This worked; but, as time went on, I came to realize that it would have been much easier (and far less error-prone) to have used the AngularJS Scope tree (hierarchy) as a publish and subscribe (Pub/Sub) mechanism.
Run this demo in my JavaScript Demos project on GitHub.
I originally went down the "event service" path because I didn't know how to unify a workflow that was consumed both by components that had "$scope" as well as by components that lacked "$scope." So, I created an eventing mechanism that didn't rely on scope and left it up to the calling context to figure out how best bind and unbind event handlers.
But, in retrospect, this was probably overkill. And, over-complicated. A simpler approach would be to use the AngularJS Scope tree as the pub/sub mechanism. The scope tree is available to the Controllers as "$scope" and to the Services as "$rootScope". It was this latter detail - the injectability of $rootScope - that really held me back. At the time, I wasn't able to fully wrap my head around the power of injecting $rootScope into your application singletons.
Once you realize that the entire application has access to the Scope tree, you can quickly see that Scope.$on() and Scope.$broadcast() can be used to bind and trigger events application-wide. And, the bonus prize is that when a scope is destroyed, its scope-based event bindings are automatically unbound, for all intents and purposes. This greatly simplifies the publish and subscribe workflow as much of the cleanup is done for you by the framework.
NOTE: When you bind to a scope event, a deregistration method (aka, unbind method) is returned. This can be used to unbind the given event handler.
To explore this concept, I put together a small demo in which N-different Controllers communicate with a data-service singleton, friendService. As the data repository is mutated, it broadcasts "created" and "deleted" events to the application. The various Controllers listen for this event and then update (or refresh) their local view-model as necessary.
Notice that I am injecting the $rootScope into the friendService as a hook into the AngularJS scope tree.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Using The Scope Tree As A Publish And Subscribe (Pub/Sub) Mechanism In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController">
<h1>
Using The Scope Tree As A Publish And Subscribe (Pub/Sub) Mechanism In AngularJS
</h1>
<!-- BEGIN: Form. -->
<form
ng-repeat="formInstance in formInstances"
ng-controller="FormController"
ng-submit="processForm()"
class="form">
<p>
<strong>You have {{ friends.length }} friends!</strong>
</p>
<ul ng-if="friends.length">
<li ng-repeat="friend in friends">
{{ friend.name }} … <a ng-click="deleteFriend( friend )">delete</a>
</li>
</ul>
<p>
<input type="text" ng-model="form.name" />
<input type="submit" value="Add Friend" />
</p>
</form>
<!-- END: Form. -->
<!-- BEGIN: Instance Tools. -->
<div class="form-actions">
<a ng-click="addFormInstance()" class="add">+</a>
<a ng-click="removeFormInstance()" class="remove">–</a>
</div>
<!-- END: Instance Tools. -->
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.26.min.js"></script>
<script type="text/javascript" src="../../vendor/lodash/lodash-2.4.1.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope ) {
// I provide the unique indices of the forms; this is done because the
// ngRepeat directive can only *sort of* do for-loops.
$scope.formInstances = [ 0 ];
// ---
// PUBLIC METHODS.
// ---
// I create a new form instance.
$scope.addFormInstance = function() {
$scope.formInstances.push( $scope.formInstances.length );
};
// I remove the most recent form instance.
$scope.removeFormInstance = function() {
$scope.formInstances.pop();
};
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the form that shows a list of friends and allows friends to be added
// and removed from said list.
app.controller(
"FormController",
function( $scope, friendService, _ ) {
// I hold the form data for ngModel.
$scope.form = {
name: ""
};
// I hold the list of friends.
$scope.friends = [];
// PUB/SUB: As the friends repository is mutated, it will publish events
// to the application. As that happens, we want to catch and response to
// those events in order to keep our data synchronized.
var unbindFriendCreated = $scope.$on( "friendCreated", handleFriendCreated );
var unbindFriendDeleted = $scope.$on( "friendDeleted", handleFriendDeleted );
// Initialize the local data.
loadRemoteData();
// ---
// PUBLIC METHODS.
// ---
// I delete the given friend from the list.
$scope.deleteFriend = function( friend ) {
friendService.deleteFriend( friend.id );
};
// I process the form, adding a new friend with the given name.
$scope.processForm = function() {
if ( ! $scope.form.name ) {
return;
}
friendService.addFriend( $scope.form.name );
$scope.form.name = "";
};
// ---
// PRIVATE METHODS.
// ---
// When a new friend is added to the repository, let's reload the local
// data to make sure it is up-to-date.
function handleFriendCreated( event, id ) {
// Log this event so we can confirm that pub/sub event handlers are
// properly unbound as scopes are destroyed.
console.info( "friendCreated event on scope", $scope.$id );
// If we already have this friend locally, then don't bother reloading
// the data. This typically means that the friend was added by our
// Controller and this it the "echo" event from our action.
if ( _.find( $scope.friends, { id: id } ) ) {
return;
}
loadRemoteData();
}
// When a friend is removed from the repository, let's remove it from the
// local collection to make sure it is up-to-date.
function handleFriendDeleted( event, id ) {
$scope.friends = _.reject( $scope.friends, { id: id } );
}
// I initialize the local data.
function loadRemoteData() {
$scope.friends = friendService.getFriends();
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide access to the Friends repository. As the repository is altered, the
// following events will be broadcast on the scope tree:
// --
// * friendCreated ( event, id )
// * freindDeleted ( event, id )
// --
// CAUTION: For the sake of this demo, I'm keeping all the data local and I'm not
// using promises. Normally, I would wrap all data-tier responses in a promise;
// but, in order to keep this demo as simple as possible, I'm foregoing the $q
// service and I am just returning raw data.
app.service(
"friendService",
function( $rootScope, _ ) {
// I am the auto-incrementing ID for the friend instance.
var pkey = 0;
// I hold the collection of friends in the repository.
var friends = [];
addFriend( "Kim" );
addFriend( "Tricia" );
// Return the public API.
return({
addFriend: addFriend,
deleteFriend: deleteFriend,
getFriends: getFriends
});
// ---
// PUBLIC METHODS.
// ---
// I add a new friend with the given name. Returns the new ID.
function addFriend( name ) {
var nextID = pkey++;
friends.push({
id: nextID,
name: name,
createdAt: ( new Date() ).getTime()
});
// PUB/SUB: Announce the created event to the application.
// --
// NOTE: Since we are not using an asynchronous request for data, we
// don't have to trigger a new $digest phase.
$rootScope.$broadcast( "friendCreated", nextID );
return( nextID );
}
// I delete a friend with the given ID. Returns void.
function deleteFriend( id ) {
friends = _.reject( friends, { id: id } );
// PUB/SUB: Announce the deleted event to the application.
// --
// NOTE: Since we are not using an asynchronous request for data, we
// don't have to trigger a new $digest phase.
$rootScope.$broadcast( "friendDeleted", id );
}
// I get all of the fiends in the repository.
function getFriends() {
// Make sure to return a COPY of the data so that we don't break
// encapsulation to our LOCAL data store.
return( angular.copy( friends ) );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I expose the Lodash / Underscore library as a service that can be injected
// into other services.
app.factory(
"_",
function( $window ) {
var _ = $window._;
delete( $window._ )
return( _ );
}
);
</script>
</body>
</html>
As you can see, the friendService "publishes" events on the $rootScope. The various Controllers then "subscribe" to those events on their local scope and respond as needed. Now, I am not demonstrating it in this code, but the Controllers can also publish events on the scope tree; however, they would want to do so on the $rootScope as well, rather than on their local scope. This way, the events are sure to be broadcast to all aspects of the AngularJS application.
You might look at this and think that it's inefficient due to the depth and breadth of the scope tree; however, keep in mind that event propagation is probably not your bottleneck - DOM (Document Object Model) manipulation is. Also, the Scope.$broadcast() method is surprisingly smart about how it works. That, and the fact that AngularJS automatically unbinds event-handlers in the $destroy() lifecycle, makes me think that the AngularJS scope tree makes a very solid publish and subscribe mechanism.
Want to use code from this post? Check out the license.
Reader Comments
Interesting article Ben, thanks,
Question, since "$scope" is 'going away" with angular 2.0, and the popularity of Controller As syntax, I'm interested to know what pub/sub methodology you would recommend when using Controller as?
Thanks again for the great articles
Bill Gerold
@Bill,
I haven't seen too much about AngularJS 2.0; but, from what I understand from various podcasts, $scope isn't really "going away". I think they are just shifting the mindset to making "modules" more of a primary construct.
Even now, with the Controller-As syntax, there is still the scope; in fact, I'm pretty sure that the Controller-As binding is just a variable reference on top of the existing scope (from the AngularJS 1.2.* source code):
`locals.$scope[ directive.controllerAs ] = controllerInstance;`
As you can see, the "as" syntax does nothing more than store a reference to the Controller in the scope to make the reference more explicit in the HTML.
In my other blog post, you can see this more directly when I attempted to backport the "as" syntax to AngularJS 1.0.8 (which is a lot of what I have in production still):
www.bennadel.com/blog/2710-implementing-controller-as-using-a-directive-in-angularjs-1-0-8.htm
So, all to say, I don't think any of this is actually changing in 2.0; at least, not from what I have heard. I think they are just trying to get people to organize their code differently.
@Bill,
... and thank you for the kind words! :D
Great post as alway!
I follow the controllerAs syntax as often recommended, and avoid injecting $scope into the controller (in order to avoid unitended dependecies, etc.)
I therefore would prefer to use $rootScope.$on and $rootScope.$emit but then how would I destroy my listener on scope destroy ? (is it even possible without injecting $scope? because If I'm already injecting $scope I may as well do $scope.$on(... and $rootScope.$broadcast...)
Hi Ben, could you tell me please, what do I need to know, to understand your code?
I mean, you are a professional javascript developer, i know javascript and angular but not at you level.
Thank you very much.
While I cant Thank you ! enough do {
Thanks !!!
}
Thanks for the thoughtful explanation
Dude, thank you so much for this blog. It's helped me enormously as I've learned Angular. My apps get a little bit better every time I come here.
I actually prefer "a centralized "event service" over scope tree in case the pub-sub is used as a major synchronization mean and the scope tree is huge since:
- the performance may get impacted by frequently broadcast over the huge tree assuming "a centralized "event service" holds a list of listener scopes and
a publishing only invokes "known" listeners in the list instead of travel the whole scope tree.
- With custom event service, we can add a lot more additional function for publish and subscribe such as filter/wildcard.
DI used by AngularJS is a nice way to decouple components but I like pub-sub better: I think pub-sub is a better way to decouple components and function parameter (DI).
Sorry some typing error:
I actually prefer "a centralized "event service" over scope tree in case the pub-sub is used as a major synchronization mean and the scope tree is huge since:
- the performance may get impacted by frequently broadcasting over the huge tree assuming "a centralized "event service" holds a list of listener scopes and
a publishing only invokes "known" listeners in the list instead of travel the whole scope tree.
- With custom event service, we can add a lot more additional function for publishing and subscribing such as filter/wildcard like you can publish "/accounting/payable" and subscribe "/accounting/*"
DI used by AngularJS is a nice way to decouple components but I like pub-sub better: I think pub-sub is a better way to decouple components than function parameter (DI).