Nick Tomlin
Nick Tomlin

Reputation: 29221

One way binding to an object in angular

I'd like to have a one-way (not one time) binding between an attribute on a directive, but i'm struggling with how to express this without attrs.$observe. The best I can come up with at the moment is to bind via &attr and invoke the variables I am binding to in my template e.g. {{attr()}}

app.controller('MainCtrl', function($scope) {
  $scope.names = ['Original'];
  setTimeout(function () {
    $scope.names.push('Asynchronously updated name');
    $scope.$apply();
  }, 1000);
});

app.directive('helloComponent', function () {
  return {
    scope: {
      'names': '&names'
    },
    template: '<li ng-repeat="name in names()">Hello {{name}}</li>'
  }
});

 <body ng-controller="MainCtrl">
    <ul>
      <hello-component names="names"/>
    </ul>
  </body>

Plunker

Is there a better way to do this that preserves the one-way binding without the need to invoke the bound properties?

Edit

I've updated the example code to clarify that I want to bind to an object, not just a string. So @attr (which works with a string attribute) is not a solution.

Upvotes: 3

Views: 10760

Answers (4)

tavnab
tavnab

Reputation: 2734

I didn't see any mention of it in the other answers, but as of Angular 1.5, one-way bindings for objects are supported (see scope section in $compile docs for Angular 1.5.9):

< or <attr - set up a one-way (one-directional) binding between a local scope property and an expression passed via the attribute attr. The expression is evaluated in the context of the parent scope. If no attr name is specified then the attribute name is assumed to be the same as the local name. You can also make the binding optional by adding ?: <? or <?attr.

For example, given <my-component my-attr="parentModel"> and directive definition of scope: { localModel:'<myAttr' }, then the isolated scope property localModel will reflect the value of parentModel on the parent scope. Any changes to parentModel will be reflected in localModel, but changes in localModel will not reflect in parentModel. There are however two caveats:

  1. one-way binding does not copy the value from the parent to the isolate scope, it simply sets the same value. That means if your bound value is an object, changes to its properties in the isolated scope will be reflected in the parent scope (because both reference the same object).
  2. one-way binding watches changes to the identity of the parent value. That means the $watch on the parent value only fires if the reference to the value has changed. In most cases, this should not be of concern, but can be important to know if you one-way bind to an object, and then replace that object in the isolated scope. If you now change a property of the object in your parent scope, the change will not be propagated to the isolated scope, because the identity of the object on the parent scope has not changed. Instead you must assign a new object.

One-way binding is useful if you do not plan to propagate changes to your isolated scope bindings back to the parent. However, it does not make this completely impossible.

In the example below, one-way binding is used to propagate changes in an object in the scope of a controller to a directive.

Update

As pointed out by @Suamere, you can indeed change properties of a bound object with one-way-binding; however, if the whole object is changed from the local model, then the binding with the parent model will break, as the parent and local scope will be referring to different objects. Two-way binding takes care of this. The code snippet was updated to highlight the differences.

angular.module('App', [])

.directive('counter', function() {
  return {
    templateUrl: 'counter.html',
    restrict: 'E',
    scope: {
      obj1: '<objOneWayBinding',
      obj2: '=objTwoWayBinding'
    },
    link: function(scope) {
      scope.increment1 = function() {
        scope.obj1.counter++;
      };
      scope.increment2 = function() {
        scope.obj2.counter++;
      };

      scope.reset1 = function() {
        scope.obj1 = {
          counter: 0,
          id: Math.floor(Math.random()*10000),
          descr: "One-way binding",
          creator: "Directive"
        };
      };

      scope.reset2 = function() {
        scope.obj2 = {
          counter: 0,
          id: Math.floor(Math.random()*10000),
          descr: "Two-way binding",
          creator: "Directive"
        };
      };
    }
  };
})

.controller('MyCtrl', ['$scope', function($scope) {
  $scope.increment = function() {
    $scope.obj1FromController.counter++;
    $scope.obj2FromController.counter++;
  };

  $scope.reset = function() {
    $scope.obj1FromController = {
      counter: 0,
      id: Math.floor(Math.random()*10000),
      descr: "One-way binding",
      creator: "Parent"
    };

    $scope.obj2FromController = {
      counter: 0,
      id: Math.floor(Math.random()*10000),
      descr: "Two-way binding",
      creator: "Parent"
    };
  };

  $scope.reset();
}])

;
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.6/angular.js"></script>

<div ng-app="App">

  <script type="text/ng-template" id="counter.html">
    <h3>In Directive</h3>
    <pre>{{obj1 | json:0}}</pre>

    <button ng-click="increment1()">
      Increment obj1 from directive
    </button>

    <button ng-click="reset1()">
      Replace obj1 from directive (breaks binding)
    </button>

    <pre>{{obj2 | json:0}}</pre>

    <button ng-click="increment2()">
      Increment obj2 from directive
    </button>

    <button ng-click="reset2()">
      Replace obj2 from directive (maintains binding)
    </button>

  </script>

  <div ng-controller="MyCtrl">
    
    <counter obj-one-way-binding="obj1FromController"
             obj-two-way-binding="obj2FromController">
    </counter>
    
    <h3>In Parent</h3>

    <pre>{{obj1FromController | json:0}}</pre>
    <pre>{{obj2FromController | json:0}}</pre>

    <button ng-click="increment()">
      Increment from parent
    </button>
    
    <button ng-click="reset()">
      Replace from parent (maintains binding)
    </button>

  </div>
</div>

Upvotes: 3

Kyle Zimmer
Kyle Zimmer

Reputation: 310

This may be essentially the same approach proposed by New Dev, but I solved a similar problem for myself by taking an object off of my isolate scope and creating a getter function for it which called scope.$parent.$eval(attrs.myObj).

In a simplified version that looks more like yours I changed:

app.directive('myDir', [function() {
    return {
        scope : {
            id : '@',
            otherScopeVar : '=',
            names : '='
        },
        template : '<li ng-repeat="name in names">{{name}}</li>'
    }
}]);

to

app.directive('myDir', [function() {
    return {
        scope : {
            id : '@',
            otherScopeVar : '='
        },
        template : '<li ng-repeat="name in getNames()">{{name}}</li>',
        link : function(scope, elem, attrs) {
            scope.getNames() {
                return scope.$parent.$eval(attrs.myList);
            };
        }
    }
}]);

That way whenever a digest runs your object is pulled as is from the parent scope. For me, the advantage to doing it this way was that I was able to change the directive from two-way to one-way binding (which took my performance from unusable to working fine) without changing the views that used the directive.

EDIT On second thought I am not sure this is exactly one-way binding, because while updating the variable and running a digest will always use the updated object, there is no inherent way to run other logic when it changes, as one could with a $watch.

Upvotes: -2

New Dev
New Dev

Reputation: 49590

The "&" is actually the right thing to do. I have argued against this approach (with @JoeEnzminger, here and here) on the basis that it is semantically questionable. But overall Joe was right - this is the way to create a one-way binding to an actual object vs. "@" which binds to a string.

If you don't fancy an isolate scope, then you could get the same effect by using $parse:

var parsedName = $parse(attrs.name);
$scope.nameFn = function(){
  return parsedName($scope);
}

and use it in the template as:

"<p>Hello {{nameFn()}}</p>"

Upvotes: 5

Mike Robinson
Mike Robinson

Reputation: 25159

Doing an attribute literally passes a string. So instead of doing this:

<hello-component name="name"/>

You can do this:

<hello-component name="{{name}}"/>

Upvotes: 0

Related Questions