Working With Inherited Collections In AngularJS
In an AngularJS application, $scope instances are all part of a prototypal chain. That is, every $scope instance (except $rootScope, I believe) has, as its prototype, the $parent scope. This is a brilliant way to architect the view-model because it means that data loaded in one controller can be accessed by controllers (and views) farther down in the same $scope chain. But, does the "visibility" of this data imply anything about which controllers should modify this data? From a functional standpoint, no - any controller that sees a $scope value can mutate that $scope value. But, from a philosophical standpoint, I'm starting to believe that inherited data should only be directly modified by the controller that owns it.
NOTE: What follows may not play nicely with ngModel - when thinking about this stuff, I did not really consider how ngModel works.
Last week, I blogged about variable access in sub-classed components. In the blog post, which was inspired by the book, Fundamentals of Object-Oriented Design In UML by Meilir Page-Jones, I discussed the idea that a sub-class should only reference those properties and methods of its super-class that the super-class has already exposed publicly. In other words, a sub-class should never access the private variables defined in the super-class. Doing so creates a very tight coupling between the sub-class and the actual implementation of the super-class.
After looking at the super-class/sub-class relationship, I wondered if the same kind of philosophy could be applied to the prototypal $scope chain in AngularJS? In a recent MTV Meetup, Misko Hevery did talk about the "access philosophy" of the $scope object; but, he talked about it in terms of View vs. Controller. That is, the View should treat the $scope as read-only and the Controller should treat the $scope at write-only.
Now, Hevery doesn't speak to my question exactly, but he does point out the non-symmetrical behavior of the $scope. My question adds a constraint to this, asking if the same non-symmetrical behavior should be applied to different Controllers in the same $scope chain?
As I write more and more AngularJS, I find that my biggest problem is that some Controller will modify some $scope data and some of my other, relevant Controllers will never find out about this change. Sure, I can start adding $watch() statements to see if something specifically has changed; but, when I start dealing with things like cached data or things like collections, the $watch() statements either become too computationally expensive (ex, a deep-compare of an array) or they simply fail to catch all changes.
I'm starting to realize that if I lock down $scope mutation to its given Controller, I can maintain a singular source of truth which facilitates the broadcasting of data mutations throughout the $scope chain. Now, this doesn't mean that other controllers can't mutate "inherited $scope;" it simply means that those controllers have to ask for the mutations rather than applying them directly.
To codify this, let's look at an example with several levels of $scope and controller. Imaging that, in the root of my application, I have a collection of friends. This collection is then broken-down by gender. And, in each gender breakdown, a friend can have his or her gender toggled, which will transfer that friend between gender lists. Also imagine that I have a form that can add new friends to the collection.
This gives us the following scope/controller combinations:
- Application
- .... Male Friends
- .... .... Friend
- .... Female Friends
- .... .... Friend
- .... New Friend Form
Since the friend collection exists in the root $scope of the application, it implies that every single one of the above controllers can change it; but, I'm saying that they shouldn't. I'm saying that any time a friend needs to be added, edited, or deleted, the local controller should make this change through a message passed up to the root $scope - the scope that "owns" the friend collection.
Let's see this in code - it's a good deal of code, so you may be better off watching the video:
<!doctype html>
<html ng-app="Demo" ng-controller="AppController">
<head>
<meta charset="utf-8" />
<title>
Working With Inherited Collections In AngularJS
</title>
<style type="text/css">
p, li {
font: 14px verdana ;
transition: text-indent .2s ease ;
}
li.activated {
color: #FF0000 ;
text-indent: 2px ;
}
form {
margin-top: 35px ;
}
</style>
</head>
<body>
<h1>
Working With Inherited Collections In AngularJS
</h1>
<p>
You have {{ friends.length }} friends!
</p>
<!-- BEGIN: Male Friends. -->
<div ng-controller="MaleFriendsController">
<h2>
{{ maleFriends.length }} Men
</h2>
<ul ng-show="maleFriends.length">
<li
ng-repeat="friend in maleFriends"
ng-controller="FriendController"
ng-mouseenter="activate()"
ng-mouseleave="deactivate()"
ng-class="{ activated: isActivated }">
{{ friend.name }}
—
<a ng-click="toggleGender( friend )">toggle</a>
</li>
</ul>
<p ng-hide="maleFriends.length">
<em>Oh noes! You have no male friends</em>.
</p>
</div>
<!-- END: Male Friends. -->
<!-- BEGIN: Female Friends. -->
<div ng-controller="FemaleFriendsController">
<h2>
{{ femaleFriends.length }} Women
</h2>
<ul ng-show="femaleFriends.length">
<li
ng-repeat="friend in femaleFriends"
ng-controller="FriendController"
ng-mouseenter="activate()"
ng-mouseleave="deactivate()"
ng-class="{ activated: isActivated }">
{{ friend.name }}
—
<a ng-click="toggleGender( friend )">toggle</a>
</li>
</ul>
<p ng-hide="femaleFriends.length">
<em>Oh noes! You have no female friends</em>.
</p>
</div>
<!-- END: Female Friends. -->
<!-- BEGIN: Friend Form. -->
<form
ng-controller="FormController"
ng-submit="addFriend()">
<p>
Add Friend:
</p>
<p>
<input type="text" ng-model="form.name" size="20" />
<select
ng-model="form.gender"
ng-options="gender.label for gender in genders">
</select>
<input type="submit" value="Add Friend" />
</p>
</form>
<!-- END: Friend Form. -->
<!-- Load jQuery and AngularJS from the CDN. -->
<script
type="text/javascript"
src="//code.jquery.com/jquery-2.0.0.min.js">
</script>
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js">
</script>
<script
type="text/javascript"
src="//cdnjs.cloudflare.com/ajax/libs/lodash.js/1.2.1/lodash.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 am the root collection of friends. The other
// collections will be derived from this collection;
// but will be "owned" by other controllers.
$scope.friends = [
{
id: 1,
name: "Vin Diesel",
gender: "M"
},
{
id: 2,
name: "Helena Bonham Carter",
gender: "F"
},
{
id: 3,
name: "Lori Petty",
gender: "F"
},
{
id: 4,
name: "Jason Statham",
gender: "M"
}
];
// ---
// PUBLIC METHODS.
// ---
// I add a new friend to the collection.
$scope.addFriend = function( name, gender ) {
$scope.friends.push({
id: ( new Date() ).getTime(),
name: name,
gender: gender
});
// When the collection is updated, the change
// must be announced down the $scope chain so
// that other controllers will know about the
// change (and can react to it).
$scope.$broadcast( "friendsChanged" );
};
// I toggle the given friend's gender between male
// and female.
$scope.toggleGender = function( friend ) {
friend.gender = invertGender( friend.gender );
// When the collection is updated, the change
// must be announced down the $scope chain so
// that other controllers will know about the
// change (and can react to it).
$scope.$broadcast( "friendsChanged" );
};
// ---
// PRIVATE METHODS.
// ---
// I return the other gender (assuming a two-gender
// system for this demo).
function invertGender( gender ) {
return( ( gender === "M" ) ? "F" : "M" );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the male-friends area.
app.controller(
"MaleFriendsController",
function( $scope ) {
// I am the gender-based breakdown of friends.
$scope.maleFriends = getFriendsForGender();
// ---
// WATCHERS.
// ---
// I listen for changes to the friends collection to
// be announced on the scope chain. Whenever the root
// collection is changed, I have to recalculate the
// gender-based breakdown.
$scope.$on(
"friendsChanged",
function( event ) {
$scope.maleFriends = getFriendsForGender();
}
);
// ---
// PRIVATE METHODS.
// ---
// I get filter the inherited friends for the target
// gender being showcased by this controller.
function getFriendsForGender() {
var friends = _.filter(
$scope.friends,
function( friend ) {
return( friend.gender === "M" );
}
);
return( friends );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the female-friends area.
app.controller(
"FemaleFriendsController",
function( $scope ) {
// I am the gender-based breakdown of friends.
$scope.femaleFriends = getFriendsForGender();
// ---
// WATCHERS.
// ---
// I listen for changes to the friends collection to
// be announced on the scope chain. Whenever the root
// collection is changed, I have to recalculate the
// gender-based breakdown.
$scope.$on(
"friendsChanged",
function( event ) {
$scope.femaleFriends = getFriendsForGender();
}
);
// ---
// PRIVATE METHODS.
// ---
// I get filter the inherited friends for the target
// gender being showcased by this controller.
function getFriendsForGender() {
var friends = _.filter(
$scope.friends,
function( friend ) {
return( friend.gender === "F" );
}
);
return( friends );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the friend list item. NOTE: This controller
// does NOT REALLY DO MUCH - it's only here to demonstrate
// how many different levels of controllers will have access
// to data provided in an inherited $scope chain.
app.controller(
"FriendController",
function( $scope ) {
// I determine whether or not the list item is "on."
$scope.isActivated = false;
// ---
// PUBLIC METHODS.
// ---
// I activate the list item.
$scope.activate = function() {
$scope.isActivated = true;
};
// I deactivate the list item.
$scope.deactivate = function() {
$scope.isActivated = false;
};
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the add-friend form.
app.controller(
"FormController",
function( $scope ) {
// Define the genders for the dropdown.
$scope.genders = [
{
label: "Male",
value: "M"
},
{
label: "Female",
value: "F"
}
];
// Define the initial form data.
$scope.form = {
name: "",
gender: $scope.genders[ 0 ]
};
// ---
// PUBLIC METHODS.
// ---
// I create the new friend.
$scope.addFriend = function() {
if ( ! $scope.form.name ) {
return;
}
// Pass the "add" message up the scope chain so
// the collection-owner controller can update the
// collection with the new friend.
$scope.$parent.addFriend(
$scope.form.name,
$scope.form.gender.value
);
// Reset the form.
$scope.form.name = "";
};
}
);
</script>
</body>
</html>
Some of this code is more complicated and distributed than it may need to be; but, in a small demo, it's hard to describe a scenario in which there are many nested controllers all making some use of shared data. That said, notice that none of the controllers ever changes data that it doesn't "own." The FriendController handles only the ngRepeat-related data; the FormController handles only its ngModel data; all mutations related to the core Friends collection are made through the root $scope and its controller.
Then, instead of dealing with $watch() statements which execute [potentially] many times in every $apply/$digest lifecycle, the root $scope simply $broadcast()'s when changes have occurred. This allows all "sub" controllers to know when the Friends collection has changed and they can react accordingly (ie. updating their gender-based breakdowns, in my demo).
My approach to sharing $scope data across nested controllers has definitely evolved greatly over time (and continues to do so). And, while this approach might feel like it has more overhead, the huge advantage is that it keeps controllers and $scope objects more loosely coupled. This means that changes made to one controller have a much lower chance of requiring parallel changes to be made in another controller.
Want to use code from this post? Check out the license.
Reader Comments
Nice post.
I have a question. In your form controller you do
$scope.$parent.addFriend(...)
So this form controller assume that it's parent had a addFriend method. Looks weird to me but I'm not an angularJS expert yet
@Seb,
The "poor" analogy would be that "$parent" is like calling "super" in an inheritance relationship in an object-oriented language. In this case, the form knows that the root Controller has exposed a public method called "addFriend()". The problem is, the form also wants to have a method called "addFriend()." So, in order to have two different methods with the same name, the FormController has to pass the action up to the root controller via $parent.
Keep in mind that if the method in the FormController was something more unique, like submitForm(), we wouldn't have to do this:
In this case, since there is no name conflict, there is no need to call $parent since the request for "addFriend()" will automatically search the $scope chain.
Nice post, I have not read much about AngularJS, but if I wanted the method
accessible for both controllers in a way that was not necessary to repeat the code, just change the parameter (F or M), how could it be done?
I think in AppController.
Great post. Thanks.
One question: Why you name addFriend methods in form controller and app controller with the same name. I mean how you go about testing this. This might cause confusion. What if you name the one in form ctrl differently so that when you call addFirend anywhere on the scope, I know there will be only one addFriend and I don't use $parent on $scope. Or am I missing something here?!
@Samuel,
Good question - that was also bothering me when I wrote the code. Yes, I could have moved it up into AppController. The reason that I didn't for this demo was that I didn't want the AppController to know that "Friends" was going to be broken down by gender; I wanted that information to be located only at the next level down of Controllers.
If I were writing this in a real app, I probably would have dont that - moved a gender-based function up into the $scope that *also* had the "friends" collection.
It might also be interesting to make a "class" for FriendCollection and then give it instance methods for breaking the collection apart. But, I haven't done much code like that, so I can't really say much about it.
@Ali,
You are right - it is confusing. I should have just named it "saveForm()" or "submitForm()" or something to that effect. Then, the saveForm() method could have simply validated the form data and made a direct call to $scope.addFriend() and not had to do anything tricky with the $parent reference.
I actually just ran into this same situation with a demo I was putting together.
Your implementation of multi-lvl $scope's > Mine
:)
@Mike,
The relationship of $scope values is definitely an interesting thing! But it's not simple - it really forces you to understand prototypal inheritance, which is not at all a simple topic! Glad you like my example :D
I recognize the applicability of your solution, and how easy it makes to share data across multiple views or even "submodules" of rather simple application. But it seems to me that it creates a relationship between sub-parts of the application tighter than it should.
Perhaps sharing information among units would be better achieved through services designed to be independent of any scope hierarchy.
@mike, @ben,
The best article about scope and prototypal prototypical inheritance in angularjs is http://stackoverflow.com/questions/14049480/what-are-the-nuances-of-scope-prototypal-prototypical-inheritance-in-angularjs
@Diego,
It definitely creates coupling between the controllers in so much as the sub-controller is depending on the "public api" exposed (via the $scope) by the super-controller (so to speak). But sometimes, I think controllers are designed to work in conjunction. Take, as an example, a controller on an ngRepeat - definitely this is coupled to the data that view is providing in the ngRepeat expression, which is based on data provided by a controller higher-up.
Or imagine a "toolbar" that is part of a UI - you may want to add a controller to the toolbar simply to make interactions a little easier to orchestrate; but, the toolbar controller still depends on its parent View and controller for data.
Typically, I do try to make the controllers as decoupled as possible. And, as you suggest, having two different controllers talk to a Service can definitely help with this. I'm still learning about all this as I go - much of this post is a reaction to things that I have done "wrong" in the past :)
@Seb,
Dang - that was a good article! Awesome graphics explaining when a reference points to one scope vs. another scope. Great find!
Hey Ben, nice technique you've brought up.
I'm building an app using Angular at the moment, and I'm still figuring out wether to use inherited scopes, or services for the data handling.
One point I was wondering about, when I went through your article and the docs is, there is also an $emit function, allowing a child controller to emit events upwards, to parent controllers. Thinking about your example using $scope.$parent.addFriend(), the $emit function might be a way to reduce coupling between the two controllers.
Have you tried this yet, and if yes, have you been able to make it work similar to your example?
I thought about emitting an 'addfriend' event with arguments attached, and have a parent controller catch that event and handle it.
Sweet and Excellent way to explain the encapsulation issue.
I am a backbone programmer and it was very difficult for me to map the backbone's model/collection to the angular controller.
Now, I see the point, though the controller has the power to change a data, we should not do that, instead the controller have to trigger events and appropriate controller should handle the changes.
Thus, we can give the encapsulation just like backbone gives us. ;)
Yes I think this controller nesting is the right approach, providing access to $watched properties. Ember has a concept called 'itemController' that imitates the same thing except with the option of declaring this on the parent controller, for example MyArrayController.itemController = MyObjectController, or using your example MaleFriendsController.itemController = FriendController