Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I communicate a settings object from a controller to services?

tl;dr;

I need to communicate state which several services need and originates in data bound to the scope of a controller. What would a good and 'Angular zen' way to do so?

Back story

I'm developing a single page application and after much thought have decided to use AngularJS. The pages are laid out in a way similar to:

enter image description here

The actual layout doesn't matter much, the concept remains the same for similar layouts. I need to communicate information that is bound to the scope of SettingsController to the services the controllers in the ngView require. I also need to update the content obtained from the service in the controller when the users make a modification to any slider.

What I've tried

The only way I've thought of is something like : http://jsfiddle.net/5sNcG/ where I have to write a binding myself and add listeners to the scope changing. I'm probably way off here and there is an obvious 'Angular' way of doing this - however despite my efforts I'm unable to find it.

/code from fiddle.
var app = angular.module("myApp",[]);

app.controller("HomeCtrl",function($scope,FooService,$interval){
    FooService.change(function(){
        console.log("HI",FooService.getFoo());
        $scope.foo = FooService.getFoo();
    });
});

app.factory("Configuration",function(){
    var config = {data:'lol'};
    var callbacks = [];
    return {
        list:function(){ return config;},
        update:function(){
            callbacks.forEach(function(x){ x();});
        },
        change:function(fn){
            callbacks.push(fn); // I never remove these, so this is a memory leak!
        }
    }
});
app.service("FooService",function(Configuration){
    return {
        getFoo: function(){
            return Configuration.list().data+" bar";    
        },change:function(fn){
            Configuration.change(fn);    
        }
    }
});
app.controller("SettingsCtrl",function($scope,Configuration){

    $scope.config = Configuration.list();
    $scope.$watch('config',function(){
        Configuration.update();
    },true);
});

I've also considered $rootScope broadcasts but that just seems like more global state

No matter what I try, I have a singleton with global state and we all know I don't want a singleton.

Since this seems like a fairly common Angular use case. What's what's the idiomatic way to solve this problem?

like image 975
Benjamin Gruenbaum Avatar asked Feb 02 '14 19:02

Benjamin Gruenbaum


4 Answers

I have faced something similar in the past, and considered four possible approaches:

  • Using $broadcast
  • Storing settings on $rootScope
  • Observer pattern (as you have here)
  • Using $watch from a controller

Here are my thoughts on each:

$broadcast

In an AngularJS presentation I saw, Miško Hevery spoke about the use of $broadcast (i.e. events) and the use cases for such. The gist was that $broadcast is more intended for reacting to events that are not closely coupled with whatever you are working with, otherwise an alternative is likely preferable. Also on this subject, the Best Practices guide on the angular wiki recommends that:

Only use .$broadcast(), .$emit() and .$on() for atomic events: Events that are relevant globally across the entire app (such as a user authenticating or the app closing).

Here, as you have settings which are closely associated to whatever populates ng-view, it would suggest an alternative to using $broadcast is preferable.

$rootScope

This is a global state as you mention (and want to avoid). It wasn't/isn't my personal preference either to expose settings to my entire app, despite it often being the easy option. I personally reserve $rootScope for configuration settings and 'soft' variables, like page title etc. I wouldn't elect to use this option.

Observer Pattern

Registering callbacks against the Configuration factory is a solid approach. In regard to your persistent callbacks, you can listen for the $destroy event on the scope, calling a remove method on your Configuration factory to remove the callback. This could be considered a good example of how $broadcast be used; the controller is concerned with the event and must react to it, but the event itself is not specific to the data shared by the controllers/Configuration service.

$watch

By using a shared service, it can be injected it into any controller concerned with the settings. Right now, any change to the config will trigger your callback, when perhaps some views may only be concerned with one or two configuration settings. $watch will allow you to easier observe changes to only those attributes. I can't speak to the overhead vs registering callbacks, but this feels like the most 'angular' way to me.

This is how this could be implemented using $watch:

var app = angular.module("myApp",[]);

app.factory("Configuration",function(){
   var data = {
     settingOne: true,
     settingTwo: false 
   };
   return data;
})

app.controller("SettingsCtrl",function($scope, Configuration){
  // do something
})

app.controller("HomeCtrl",function($scope, Configuration){
   // detect any change to configuration settings
   $scope.$watch(function() {
     return Configuration;
   }, function(data) {
     // do something
   }, true)

   // alternatively only react to settingTwo changing
   $scope.$watch(function() {
     return Configuration.settingTwo
   }, function(data) {
     // do something
   })
})

Note that if you were to require a slightly more complicated Configuration factory, you could shift to using getter/setter methods and keep the config settings themselves private. Then, in the $watch, you should watch the method call instead of the property itself.

UPDATE:

At the time of answering, I preferred the approach of a $watch within a controller. After some time developing with the framework, I now try to keep $watch out of the controller altogether, instead preferring, where possible, to directly invoke a function at the point of change of the value, or through leveraging ng-change.

One reason for such is the complexity it adds to testing the controller, but perhaps moreso that it's inefficient: for every $digest cycle angular invokes, every registered $watch will be evaluated regardless, and it may very well be responding to a change made to a value with an existing $watch.

Rather than surmize the cons and solutions on this perspective, there is a very good article on exactly this issue here: Angular JS - you probably shouldn't use $watch in your controllers.

like image 96
scarlz Avatar answered Nov 08 '22 10:11

scarlz


UPDATE :)

I made a demo plunker :http://plnkr.co/edit/RihW4JFD8y65rDsoGNwb?p=preview


There are lots of different ways and this question is somehow very broad, but I wanted to share another approach.

Your problem starts with the way you communicate between controllers and services.

Instead of creating services as objects with methods (which enforce you to use the observer pattern) , you can point scopes directly to services by creating data objects services and let $digest make the work for you.

The very reason why angular uses $scope is to let you use POJO rather than an observer pattern like other frameworks. When you create these kind of method driven services you introduce the same pattern that angular tries to avoid.

It's important to note that you must point to properties on the service object and not to the object reference itself.

This is an example:

app.factory('FooService', function($rootScope, Configuration){
  $scope = $rootScope.$new();
  $scope.Configuration = Configuration;

  var foo = {
    data : null
  };

  $scope.$watch('Configuration', function(config){
    foo.data = // do something with config
  }, true)

  return foo;

});

app.factory('Configuration', function(){
  return {
    data : 'lol'
  }
});

app.controller('SettingsCtrl', function($scope, Configuration){
  $scope.config = Configuration;
});

app.controller("HomeCtrl",function($scope, FooService){
   $scope.foo = FooService;
});

Actually, I prefer the use of $scope.$broadcast mostly for performance reasons but also because it's the most elegant way to share states across differnet parts of the application. I really don't care about global states, I use namespaces for my events.

like image 30
Ilan Frumer Avatar answered Nov 08 '22 10:11

Ilan Frumer


You have two options:

  1. Use shared service in combination with $watch. I.e. what you implemented, or put differently the Mediator Pattern.
  2. Use $scope.$broadcast and/or $scope.$emit with $scope.$on events to communicate changes.

Personally I don't see anything wrong with option (1). I also don't consider that to be global state in the traditional sense. Your configuration is contained within the Configuration service, you can only access this service through DI injection which makes it testable.

One possible improvement is to create a ConfigMediator service and move the update and callback functionality to it to separate concerns.

like image 1
Beyers Avatar answered Nov 08 '22 10:11

Beyers


Very interesting question.

We've been using angular for a few months now and are currently considering how this can be done better. We're still trying to figure out what can be an optimal solution, maybe this will help in getting there.

I think the original solution you've provided is pretty similar, but there are a few considerations which should be taken:

  1. From what we've experienced, most of the changes in settings require some specific logic and therefore a dedicated handlers.
  2. Config should be changed only after change is completed / valid / saved (e.g - if many elements are binded to the user, they shouldn't change until the change was completed)
  3. Using rootScope.$emit and rootScope.$on give a nice pub/sub implementation. Simple conventions can be used to namespace the events

I also believe using a shared service which is injected when needed is the way to go.

I've modified Ilan Frumer's cool plunker example: http://plnkr.co/edit/YffbhCMJbTPdcjZDl0UF?p=preview

Breaking down the problem into two can help in thinking of what can be a solution.

Updating the config service with changes done in the settings page

For this, using a $watch looks like the optimal solution, you wait for the specific config to be changes and as a response let the config service know what have been changed. I prefer of doing it explicitly, to keep the flow of the change clear and consistent.

This can be done by making a local copy of the Configuration data and watching for changes.

app.factory('Configuration', function($rootScope){
  return {
    var config = {
      user: "xxxx"
    }

    return {
      config: config,

      set: function(item, value) {
        config[item] = value;
        $rootScope.$emit("configChanged." + item);
      },

      changed: function(item, callback, scope) {
        var deregister = $rootScope.$on("configChanged." + item, function() {
          callback(config[item], config)
        });

        callback(config[item], config);

        if (scope) {
          scope.$on("$destroy", deregister);
        }
      }
    }
  }
});

app.controller('SettingsCtrl', function($scope, $timeout, Configuration){
  // Get a local copy - configuration shouldn't change until change 
  // is completed
  $scope.data = angular.copy(Configuration.config);

  // Keep UI interactions in the controller
  // If more complex UI is required another way could even use a 
  // directive for this 
  $scope.$watch("data.user", function(user) {
      Configuration.set('user', $scope.data.user);
  });
});

app.factory('DetailsService', function(Configuration, $http){

  var details = {
    data : null,
  };

  Configuration.changed("user", function(user) {
    // Handle user change ... 
  });
});

How services / controllers observe changes

This also has two options.

  1. When no specific logic is required - a simple binding to the config can be done
  2. When specific logic should be made - register to the event change (like in the DetailsService above)

In case there are multiple states services, another optimization can be to extract the "set", "changed" functions to a generic implementation.

Hope it helps.

like image 3
Shmeff Avatar answered Nov 08 '22 10:11

Shmeff