One-Time Data Bindings For Object Literal Expressions In AngularJS 1.3
Yesterday, I took a look at the new one-time data bindings introduced in AngularJS 1.3. In that post, I was applying one-time data bindings to simple expressions (for lack of a better term). Today, I wanted to see how those same one-time data bindings would behave when watching object-literal expressions.
Run this demo in my JavaScript Demos project on GitHub.
Even if you've never set up an explicit $watch() for an object literal expression, the chances are great that your application has many $watch() bindings for object-literal expressions. If you've ever used the ngClass or ngStyle directives, you've implicitly set up a $watch() binding on an object literal expression:
<!--
These directives are setting up $watch() bindings for object literal
expressions behind the scenes.
-->
<div
ng-class="{ active: item.isActive, disabled: item.isDisabled }"
ng-style="{ left: ( item.x + 'px' ), top: ( item.y + 'px' ) }">
{{ item.label }}
</div>
Now, an object literal expression poses an interesting situation for a one-time data binding. According to the documentation, AngularJS uses a "value stabilization algorithm" in which it will continue to watch an expression until it results in a defined value. But, with an object literal expression, the top-level value - the object - is always defined. As such, you would think that the $watch() binding would immediately deregister itself.
As it turns out, however, this is not the case. If you dig through the AngularJS source code, you will find that AngularJS makes a special logic branch for object literal expressions. In that branch, it examines all the key-value pairs in the resultant object. And, it only deregisters the $watch() binding when all of the keys have defined values.
To see this behavior in action, I've put together a small demo in which we set up a one-time data binding for an object literal expression with two keys. Both keys start out with undefined values which can be defined independently by the user.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
One-Time Data Bindings For Object Literal Expressions In AngularJS 1.3
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController">
<h1>
One-Time Data Bindings For Object Literal Expressions In AngularJS 1.3
</h1>
<p>
<a ng-click="setValue1()">Set Value 1</a>
—
<a ng-click="setValue2()">Set Value 2</a>
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.8.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope ) {
// Setup a one-time binding for a simple string value.
$scope.$watch(
"::watchedValue1",
function handleModelChange( newValue ) {
console.log( "String:", newValue );
}
);
// Setup a one-time binding for an object literal expression (think
// ngClass and ngStyle directives). In this case, we have two undefined
// key-values that will be defined independently of each other.
$scope.$watch(
"::{ key1: watchedValue1, key2: watchedValue2 }",
function handleModelChange( newValue ) {
console.log( "Object:", newValue );
},
// Deep equality, otherwise it's a new object for every digest
// comparison and will cause an infinite digest loop.
// --
// NOTE: This has NOTHING TO DO with the one-time bindings. This is
// just how object-literal watch-bindings need to work.
true
);
// ---
// PUBLIC METHODS.
// ---
// Define / increment the first value (used by string and object literal).
$scope.setValue1 = function() {
console.info( "Incrementing value 1" );
$scope.watchedValue1 = ( ++$scope.watchedValue1 || 1 );
};
// Define / increment the second value (used only by object literal).
$scope.setValue2 = function() {
console.info( "Incrementing value 2" );
$scope.watchedValue2 = ( ++$scope.watchedValue2 || 1 );
};
}
);
</script>
</body>
</html>
Note that we have to perform a deep-object comparison for the object literal $watch(). This has nothing to do with one-time data bindings; this is done in order to prevent an infinite digest loop (which would result in a new / dirty object at the end of every digest iteration).
Using the links, we can define and increment each key-value independently. And, if we set the first value a few times and then set the second value a few of times, we get the following console output:
As you can see, as we incremented the value of the first key, our $watch() callback continued to be invoked. It was only after the second key was defined that our one-time $watch() binding deregistered itself.
I think this was definitely a smart move on behalf of the AngularJS team. Without this approach, the one-time data bindings wouldn't be usable for directives like ngClass and ngStyle. Well done!
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben.
Thanks for another great tutorial. I have one question about this feature. When one-time data binding is used with an object literal expression, does it invoke a "de-registered" event when all elements have defined values and the object is no longer watched? There may be a use case for this event when a page needs to render async data.
For example, let's say you have a 5-panel "featured news" carousel with images, article description, and url for the article. Data is aggregated from five separate feeds. The first object in each feed is featured news article information for it's panel in the carousel. I could use a simple object like {feed1: xhrData1, feed2: xhrData2, feed3: xhrData3, feed4: xhrData4, feed5: xhrData5 } to represent data in this carousel. Perhaps it makes sense to watch for a de-register event so carousel animation can begin once all async data populates the object. Do you think this would be a valid approach? What are your thoughts?
Thanks for your time.
Chris
I'm switching from angular-once (https://github.com/tadeuszwojcik/angular-once) to Angular's native one-time binding in 1.3. After reading the documentation on Angular's one-time bindings I began to write my object literal expressions like this:
data-ng-class="::options.size ? {'modal-lg' : options.size == 'large', 'modal-sm' : options.size == 'small'} : undefined"
I did a quick google search because I was sure others must have run into the same problem. Glad I found your article.
Submitted a pull request which links to your article for https://docs.angularjs.org/guide/expression to add this special case. Hopefully the documentation will get updated.
@Philip,
Can you give a bit more detail about what problem you were running into? It's not clear from your comment what your problem is, but I am curious.
@Chris,
That's a really great question! Sorry for not responding sooner. If you know that your data is going to take a while to aggregate, then I don't think you can use the one-time binding as I don't believe it is really meant for such a use-case. To me, the one-time binding is for stuff that is ready to render once and then never again.
That said, you can *still* use one-time binding if you need to aggregate data over time. The way I would approach that problem would be to defer the DOM until the data is available. Then, once it's available, use the one-time binding.
So, for example, some pseudo code:
<div ng-if=" isDataLoaded ">
. . . . render carousel . . . .
</div>
This way, you don't render the carousel DOM until the `isDataLoaded` flag is true, which you can control when you are loading the data from your various sources.
I want this expression
<div data-ng-class="{'modal-lg' : options.size == 'large', 'modal-sm' : options.size == 'small'}">
to get bound once, but only after options.size is set. In other words, I want this expression to stop getting watched once options.size is set.
In angular-once (https://github.com/tadeuszwojcik/angular-once) I used `once-wait-for` to indicate this. So my code looked like this:
<div data-once-class="{'modal-lg' : options.size == 'large', 'modal-sm' : options.size == 'small'}" data-once-wait-for="options.size">
In order to replicate this behavior using Angular's built-in one-time binding syntax, I wrote the expression like this:
<div data-ng-class="::options.size ? {'modal-lg' : options.size == 'large', 'modal-sm' : options.size == 'small'} : undefined">
because I knew the expression would get watched until it resolved to a defined value.
I did a quick Google search for "angular one time binding undefined" to see if my approach was correct or if there was a way to specify a "wait for" condition that I didn't know about. That's how I found your article.
After reading your article I reread Angular's documentation (https://docs.angularjs.org/guide/expression) and noticed they didn't mention that object literal expressions are treated differently than simple expressions. So I created a pull request to update the doc by adding a section under the "Value stabilization algorithm" section that links to your article.
I think I will keep my code as
<div data-ng-class="::options.size ? {'modal-lg' : options.size == 'large', 'modal-sm' : options.size == 'small'} : undefined">
because that still seems like the only way to have a one-time binding that waits for a condition. Is there a better way?
Thanks!!
Hi Ben,
My angular documentation pull request has been merged, so this article is now linked on the Expressions page of the Developer Guide: https://docs.angularjs.org/guide/expression#special-case-for-object-literals
Thanks for all your posts!
Philip
Thanks Ben, you answered my exact question of whether one-time data bindings were appropriate for ng-class!