The state of angularjs controllers

javascript May 6, 2014

Angular.js is based on the ever popular MVC architecture pattern. However, like many other JavaScript frameworks, MVC can very easily be muddied up and misused.

Understanding the framework you're using, whether that be Angular, Backbone, etc, and how the different M-V-? pieces work together is important to the maintainability and scalability of your application. When the ideas of these different MV? architectures get misconstrued, bad things can happen.

For example in Angular, you'll see many examples of controllers holding model state that in reality they probably should not be.

The third part, the controller, accepts input and converts it to commands for the model or view.
-- Wikipedia (1)

The controller's job is not to be the model, the controller's job is to talk to the "model".

For example, take an order controller...

angular.module('Store', [])
.controller('OrderCtrl', function(Products) {
    this.products = Products.query();
    
    this.items = [];
    
    this.addToOrder = function(item) {
        this.items.push(item);
    };
    
    this.removeFromOrder = function(item) {
        this.items.splice(this.items.indexOf(item), 1);
    };
    
    this.totalPrice = function() {
        return this.items.reduce(function(memo, item) {
            return memo + (item.qty * item.price);
        }, 0);
    };
});

What you have here is a controller that has a list of products retrieved from the Products service. Then there is a list of items that is on the OrderCtrl. At first glance, this is not unlike much of the code you'll see in Angular's docs https://docs.angularjs.org/api/ng/directive/ngController.

For a "small prototype", maybe that's fine. For larger apps however, probably not a great idea. What if you need to have a cart controller that displays the total items in your order, or the price? What if you want to have a separate order controller in some other widget?

You'll either end up duplicating code, or having to hack some way for the controllers to talk to each other to keep their state up to date.

The solution to this problem is to do utilize the "M" of MVC properly and move that state out of your controller into some "model".

With angular, there's a little bit of ambiguity in what the "M" really is. I like to think of the "model" as the actual data you retrieve from the backend, and how you retrieve it on the client. With angular the ambiguity in the "model" is made difficult in part because there are lots of different ways to create a "model" aka services, factories, and providers. That's why there are so many questions on stack overflow about it.

One of the easiest ways to deal with it is to use angular.factory as a "model".

angular.module('Store')
.factory('Order', function() {    
    var add = function(item) {
        this.items.push(item);
    };
    
    var remove = function(item) {
        if (this.items.indexOf(item) > -1) {
          this.items.splice(this.items.indexOf(item), 1);  
        }
    };
    
    var total = function() {
        return this.items.reduce(function(memo, item) {
            return memo + (item.qty * item.price);
        }, 0);
    };

    return {
    	items: [],
        addToOrder: add,
        removeFromOrder: remove,
        totalPrice: total
    };
});

You now have an injectable "model" you can span across multiple controllers, and remove the state from the controller.

angular.module('Store', [])
.controller('OrderCtrl', function(Products, Order) {
    this.products = Products.query();
    this.items = Order.items;
    
    this.addToOrder = function(item) {
		Order.addToOrder(item);
    };
    
    this.removeFromOrder = function(item) {
        Order.removeFromOrder(item);
    };
    
    this.totalPrice = function() {
    	return Order.total();
    };
});

The controller is now much thinner, and holds no state. This is important because controllers are created and destroyed very often in the lifecycle of an angular app, whereas a factory is only created a single time.

Now you can use the Order "model" in other controllers quite easily.

angular.module('Store', [])
.controller('CartCtrl', function($scope, Order) {
    $scope.items = Order.items;
  
    $scope.$watchCollection('items', function() {
      $scope.totalPrice = Order.totalPrice().toFixed(2);
    });
});

The $scope.$watchCollection can keep an eye out for changes on the Order.items array and update the $scope.totalPrice when the items change.

There are other ways of doing this such as firing an event when a new item is added to the Order, but this should work in this case. The main benefit here is that the order has been pulled out into it's own "model" that can more easily be reused in other parts of the application.

Here's a JSBin of the code above...

JS Bin

  1. http://en.wikipedia.org/wiki/Model–view–controller

Tags