Skip to content

Instantly share code, notes, and snippets.

@andrewstuart
Last active October 1, 2015 19:22
Show Gist options
  • Save andrewstuart/90ea98a7f98d2b757d52 to your computer and use it in GitHub Desktop.
Save andrewstuart/90ea98a7f98d2b757d52 to your computer and use it in GitHub Desktop.

The problem with using Angular controllerAs

For a while now, I've been silently bothered by the popularity of controllerAs syntax, how often I hear it recommended, and the reasons it's recommended. It's taken me the better part of two years to take the time to think about and write out why it goes against my gut so strongly, but I think I've figured it out.

It's not the "as" pattern itself. I love having templates that name their dependencies inline. It lowers the cognitive overhead required when reasoning about what your template is doing and displaying, and you don't have to hunt through layers of *Controller.js and *Service.js files just to find out which properties you can access from the view.

The problem that I have is that controllerAs sidesteps the real problems and masks them with a solution that I will argue does more harm than good.

  • While it gets you around having to use $parent, you still end up with components that are going further up the $scope chain than they ought to (breaking encapsulation) rather than leveraging Angular's machinery to refer to your model directly.
  • You still have to remember which controller added a method or property, what you called it, and how to use it. This means wading through controllers and services again.
  • No matter what, you're going to use $scope and its dependency chain. You're better off understanding it and using it properly rather than employing a weak workaround.
  • Using "vm" as your go-to controllerAs name throws away important contextual information.
    • This is the same issue I take with var that = this and var vm = this recommendations. If your constructor has an object name, use the instance name as the this alias instead: function Foo() { var foo = this; }. Don't make yourself think more than you need to, or end up with that, that2, that3 if you happen to need nested this references.
  • Passing a function to $scope.$watch is inefficient by nature and eschews any possible optimizations that $scope could be perform for multiple watches on the same property.

As I see it, there are two roots to the real problems here. The first is that I believe "controller" in Angular is a misnomer. The second is that it's critical in JavaScript that you understand the subtle difference between accessors and mutators and how ES-compliant engines resolve them in the presence of a prototype chain. There is a great explanation of Angular's Scopes on the wiki, but I believe the suggested workaround simply misses the best solution.

The Angular "controller" misnomer

The best description of MVC (which I think applies well to MV*) I've seen thus far is straight out of the Gang of Four Design Patterns book.

MVC consists of three kinds of objects. The Model is the application object, the View is its screen presentation, and the Controller defines the way the user interface reacts to user input.

The reason I like this definition so much is that it really cleans up the mishmash of definitions I've been given over the years about what MV* should be. This was also the first time I realized that, under this definition, an Angular "controller", i.e. app.controller(...), is not a controller at all, at least not on its own.

The real Controller in Angular is the $scope object. It both responds to user input ($watch, $observe) and takes responsibility for exposing business information to the view and updating it ($eval). So treating the "controller" functions in Angular as if they're MVC controllers is redundant.

What your controllers should really be doing is decorating $scope with domain knowledge and functionality, so that your template and directives can use it, and so that $scope can maintain it. Since $scopes have a lifecycle, the framework also takes care of disposing unneeded functionality (watches, methods) and information (properties) when it's no longer reference by the view.

No matter which way you swing it, though, an angular Controller will not be doing much on its own. Instead, it relies on $scope either directly or indirectly (via directives) to expose information and respond to user input. Under that definition, and with the goal of having thin controllers, it's almost certainly best to think of your controllers as decorators, not as classes. Yes, Angular may may instantiate them as classes, but I think that's best left as an implementation detail.

Assignment expressions, the prototype chain, and $scope.

In the Angular world, $scopes, or really any classes that leverage the prototype chain as Scope does, can end up feeling kind of like an ECMAScript Environment Record, the implementation name for a JavaScript scope. Objects inherit from their prototypes (except for isolate scopes), and with Angular $scopes, they're created for you semantically.

The key difference, and where most people get tripped up, is that an assignment within a $scope expression without an implicit object dereference (dot in the expression) will be set on the closest parent $scope. In a JavaScript scope, an assignment will traverse the Environment Records until it finds either the named reference or the window object (which will throw a ReferenceError in strict mode):

var foo = 'foo';

(function() {
  foo = 'foo2';
})()

console.log(foo); //'foo2'

On the prototype chain, however, things work slightly differently:

Named data properties of the [[Prototype]] object are inherited (are visible as properties of the child object) for the purposes of get access, but not for put access. Named accessor properties are inherited for both get access and put access.

Knowing this is key to working with $scope's behavior, rather than working around it. Let's take an example:

var scope1 = $rootScope.$new();
var scope2 = $scope1.$new(); //Creates a new Scope object with scope1 as its prototype

scope1.$eval('widgets = {list: ['bar', 'baz']}');
//Roughly equivalent to scope1.widgets = ...

//In a ValueExpression, the prototype chain is used to evaluate any 
console.log(scope2.$eval('widgets')); //{list: ['bar', 'baz']} -- Okay, a child scope.

scope2.$eval('widgets = {list: ['bar', 'baz', 'bang']}');
//Here, as in a template with a child scope, $eval effectively calls
//`scope2.widgets = {list: ['bar', 'baz', 'bang']}` (via $parse).

console.log(scope2.$eval('widgets')); //{list: ['bar', 'baz', 'bang']}
console.log(scope1.$eval('widgets')); //{list: ['bar', 'baz']} -- Wait, what?

The assignment rules mean that for put access, the value of the right hand side ({}) will be added to the base object of the assignment left hand side at the specified property key. The base object is simply the result of evaluating everything to the left of the last . or [...], or in our example: scope2. The property key is just the name after the last dot or inside the last [].

The controllerAs syntax gets around $parent calls by treating each controller as a Class that will be instantiated, and letting you reference the controller directly. Rather than applying $scope's prototypical inheritance, it forces you to ignore it. As I'll address in a minute, I believe this is the wrong response to the confusion about $scope inheritance, especially given the nature of Angular controllers as I've already addressed.

I think the right answer is to instead interact directly with the business objects of the model that exist on the scope, grouping them by their domain (widgets.list or widgets.byId[widget.id] in an ng-repeat="widget in widgets.list") rather than by execution context, which forces you to remember in which controller you added object X.

var scope3 = scope1.$new()

scope3.$eval('widgets.list[0] = \'blam\'');
//scope3.widgets.list will have its 0th element updated. The accessor for
//widgets.list will follow the reference via the prototype chain, and then the
//mutator for [0] will be called.

console.log(scope3.$eval('widgets')) //{list: ['blam', 'baz']}
console.log(scope1.$eval('widgets')) //{list: ['blam', 'baz']} -- The reference was followed first

If you find yourself having to traverse $parent chains within your view, try to reorient your code around the business object itself rather than the implementation of $scope. Knowing that widget is always a widget and will always be found in the Widgets service really saves a lot of cognitive overhead.

My current best practice

My current best practice for this is to logically group business objects in relevant services. These services typically hold lists, groupings, and indexes (service.byId, service.byName, etc.), as well as all the methods for dealing with that business object. Then, in keeping with the "thin controller" recommendation (I believe this is a reflection of the "controller's" true nature), my controllers simply expose these services directly onto the scope by their camelcase name. I've even gone so far as creating a simple directive to expose and alias single services (<ANY with-service="Widgets as widgets">) for those times when creating a controller is too much overhead just to expose a single service.

Before this, my controllers often existed to cherry-pick certain properties off of my services and expose them to the scope. This kept my $scope clean, but lead to a lot of tiptoeing inside my services to ensure that references to specific properties weren't invalidated by replacing the property.

<div ng-controller="FooController">
  <div ng-repeat="fooItem in list">
  {{fooItem}}
  </div>
</div>
app.controller('FooController', function($scope, Foo) {
  $scope.fooList = Foo.list;
}).service('Foo', function($http) {
  var foo = this;
  foo.list = [];

  //Instead of `foo.list = [];`, code like this ended up everywhere.
  foo.list.clear = function() {
    while ( foo.list.length ) {
      //Call the pop() method to modify the instance instead of replacing
      //foo.list. Otherwise, components that have already kept a reference to
      //foo.list will have an old reference.

      foo.list.pop();
    }
  };

  foo.refresh = function() {
    return $http.get('/api/foo')
      .success(function(fooList) {
        foo.list.clear();
        //Make sure I'm calling push() for each item.
        fooList.forEach(function(item) {
          foo.list.push(item);
        });
      });
  };
});

Now, my code is much more organized, obvious, domain-driven, and much less concerned with implementation:

<div with-service="Widgets as widgets">
  <div
    ng-repeat="widget in widgets.list"
    ng-class="{active: widgets.chosen === widget}">
    {{widget.title}}
    <button ng-click="widgets.remove(widget)">x</button>
  </div>
</div>
app.service('Widgets', function($http) {
  var widgets = this;
  widgets.list = [];

  widgets.refresh = function() {
    $http.get('/api/widgets')
      .success(function(list) {
        //No worries about maintaining references. The scope and other services
        //are using "fully qualified" object references.
        widgets.list = list;
      });
  };
});

Conclusion

Keeping a dot in your $scope expressions is only half of the answer. The best advice would probably be two-part:

"Keep a dot in your model, and make sure it starts with a business object."

Practically this boils down to:

  • Use $scope for what it does best: expose business data/functionality and react to user input.
  • Primarly, put object references (obj instanceof Object === true) on the $scope.
    • If possible, expose the whole service on the $scope for readability. Since services are singletons, this also effectively "fully qualifies" your references, leading to less unexpected behavior.
  • Reference exactly the object you want in your expression.
    • Prefer referencing only objects visibly named in the template. (e.g. using "with-service")
  • Remember that in assignment, the last dot or [] separates the actual object (before the dot) and the key name (after the dot).

This article has also been posted at my employer's site.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment