JavaScript
Article

Managing Client-Only State in AngularJS

By Michael Godfrey

View models in JavaScript frameworks such as AngularJS can be different from domain models on the server – a view model doesn’t even have to exist on the server. It follows then that view models can have client only state, e.g. ‘animation-started’ and ‘animation-ended’ or ‘dragged’ and ‘dropped’. This post is going to concentrate on state changes when creating and saving view models using Angular’s $resource service.

It’s actually very easy for a $resource consumer, e.g. a controller, to set state, as shown below.

angular.module('clientOnlyState.controllers')
    .controller('ArticleCtrl', function($scope, $resource, ArticleStates /* simple lookup */) {
        var Article = $resource('/article/:articleId', { articleId: '@id' });

        var article = new Article({ id: 1, title: 'A title', author: 'M Godfrey' });
        article.state = ArticleStates.NONE; // "NONE"

        $scope.article = article;

        $scope.save = function() {
            article.state = ArticleStates.SAVING; // "SAVING"

            article.$save(function success() {
                article.state = ArticleStates.SAVED; // "SAVED"
            });
        };
    });

This approach is fine for applications containing single consumers. Imagine how boring and error prone replicating this code would be for multiple consumers! But, what if we could encapsulate the state change logic in one place?

$resource Services

Let’s start by pulling out our Article resource into an injectable service. Let’s also add the most trivial setting of state to NONE when an Article is first created.

angular.module('clientOnlyState.services')
    .factory('Article', function($resource, ArticleStates) {

        var Article = $resource('/article/:articleId', { articleId: '@id' });

        // Consumers will think they're getting an Article instance, and eventually they are...
        return function(data) {
            var article = new Article(data);
            article.state = ArticleStates.NONE;
            return article;
        }
    });

What about retrieving and saving? We want Article to appear to consumers as a $resource service, so it must consistently work like one. A technique I learned in John Resig’s excellent book “Secrets of the JavaScript Ninja” is very useful here – function wrapping. Here is his implementation directly lifted into an injectable Angular service.

angular.module('clientOnlyState.services')
    .factory('wrapMethod', function() {
        return function(object, method, wrapper) {
            var fn = object[method];

            return object[method] = function() {
                return wrapper.apply(this, [fn.bind(this)].concat(
                    Array.prototype.slice.call(arguments))
                );
            };
        }
    });

This allows us to wrap the save and get methods of Article and do something different/additional before and after:

angular.module('clientOnlyState.services')
    .factory('Article', function($resource, ArticleStates, wrapMethod) {
        var Article = $resource('/article/:articleId', { articleId: '@id' });

        wrapMethod(Article, 'get', function(original, params) {
            var article = original(params);

            article.$promise.then(function(article) {
                article.state = ArticleStates.NONE;
            });

            return article;
        });

        // Consumers will actually call $save with optional params, success and error arguments
        // $save consolidates arguments and then calls our wrapper, additionally passing the Resource instance
        wrapMethod(Article, 'save', function(original, params, article, success, error) {
            article.state = ArticleStates.SAVING;

            return original.call(this, params, article, function (article) {
                article.state = ArticleStates.SAVED;
                success && success(article);
            }, function(article) {
                article.state = ArticleStates.ERROR;
                error && error(article);
            });
        });

        // $resource(...) returns a function that also has methods
        // As such we reference Article's own properties via extend
        // Which in the case of get and save are already wrapped functions
        return angular.extend(function(data) {
            var article = new Article(data);
            article.state = ArticleStates.NONE;
            return article;
        }, Article);
    });

Our controller starts to get leaner because of this and is completely unaware of how the state is being set. This is good, because the controller shouldn’t care either.

angular.module('clientOnlyState.controllers')
    .controller('ArticleCtrl', function($scope, Article) {
        var article = new Article({ id: 1, title: 'A title', author: 'M Godfrey' });
        
        console.log(article.state); // "NONE"

        $scope.article = article;

        $scope.save = function() {
            article.$save({}, function success() {
                console.log(article.state); // "SAVED"
            }, function error() {
                console.log(article.state); // "ERROR"
            });
        };
    });

Encapsulation Benefits

We’ve gone to reasonable lengths to encapsulate state changes outside our controllers, but what benefits have we gained?

Our controller can now make use of watch listeners being passed the old and new state to set a message. It could also perform a local translation, as shown below.

angular.module('clientOnlyState.controllers')
    .controller('ArticleCtrl', function($scope, Article, ArticleStates) {
        var article = new Article({ id: 1, title: 'A title', author: 'M Godfrey' });

        var translations = {};
        translations[ArticleStates.SAVED] = 'Saved, oh yeah!';
        translations['default'] = '';

        $scope.article = article;

        $scope.save = function() {
            article.$save({});
        };

        $scope.$watch('article.state', function(newState, oldState) {
            if (newState == ArticleStates.SAVED && oldState == ArticleStates.SAVING) {
                $scope.message = translations[newState];
            } else {
                $scope.message = translations['default'];
            }
        });
    });

Consider for a moment that $scopes, directives and filters form the API of an application. HTML views consume this API. The greater the composability of an API the greater it’s potential for reuse. Can filters improve composability over new versus old watching?

Composing via Filters, a Panacea?

Something like the following is what I have in mind. Each part of the expression becomes reusable.

<p>{{article.state | limitToTransition:"SAVING":"SAVED" | translate}}</p>

As of Angular 1.3, filters can make use of the $stateful property, but its use is strongly discouraged as Angular cannot cache the result of calling the filter based on the value of the input parameters. As such we shall pass in stateful parameters to limitToTransition (previous state) and translate (available translations).

angular.module('clientOnlyState.filters')
    
    .filter('limitToTransition', function() {
        return function(state, prevState, from, to) {
            if(prevState == from && state == to)
                return to;

            return '';
        };
    })

    .filter('translate', function() {
        return function(text, translations) {
            return translations[text] || translations['default'] || '';
        };
    });

Because of this we need a slight amendment to Article:

function updateState(article, newState) {
    article.prevState = article.state;
    article.state = newState;
};

wrapMethod(Article, 'get', function(original, params) {
    var article = original(params);
    article.$promise.then(function(article) {
        updateState(article, ArticleStates.NONE);
    });
    return article;
});

The end result is not quite as pretty but is still very powerful:

<p>{{article.state | limitToTransition : article.prevState : states.SAVING : states.SAVED | translate : translations}}</p>

Our controller gets leaner again, especially if you consider the translations could be pulled out into an injectable service:

angular.module('clientOnlyState.controllers')
    .controller('ArticleCtrl', function($scope, Article, ArticleStates) {
        var article = new Article({ id: 1, title: 'A title', author: 'M Godfrey' });

        // Could be injected in...
        var translations = {};
        translations[ArticleStates.SAVED] = 'Saved, oh yeah!';
        translations['default'] = '';

        $scope.article = article;
        $scope.states = ArticleStates;
        $scope.translations = translations;

        $scope.save = function() {
            article.$save({});
        };
    });

Conclusion

Extracting view models into injectable services helps us scale applications. The example given in this post is intentionally simple. Consider an application that allows the trading of currency pairs (e.g. GBP to USD, EUR to GBP etc.). Each currency pair represents a product. In such an application there could be hundreds of products, with each receiving real-time price updates. A price update could be higher or lower than the current price. One part of the application may care about prices that have gone higher twice in a row, whilst another part may care about prices that have just gone lower. Being able to watch for these price change states greatly simplifies various consuming parts of the application.

I presented an alternative method to watching based on old and new values, filtering. Both are entirely acceptable techniques – in fact watching is what I had in mind when I began researching this post. Filtering was a potential improvement identified near post completion.

I would love to see if the techniques I’ve presented help you scale Angular apps. Any and all feedback will be greatly recieved in the comments!

The code samples created while researching this post are also available on GitHub.

More:
Comments
crudbetter

There is a typo in one of the code samples. The translate filter should read:

.filter('translate', function() {
	return function(text, translations) {
		return translations[text] || translations['default'] || '';
	};
});
Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in JavaScript, once a week, for free.