Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Simon Free
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Simon Free@simonfree )

Isolate-Scope Two-Way Data Binding Is Eventually Consistent In AngularJS

By Ben Nadel on

As I've been trying to wrap my head around Isolate scopes in AngularJS, it occurred to me that the two-way data binding might be easy to misunderstand. In two-way data binding, the isolate scope can both read from and write to a given non-isolate scope value. You might think that this relationship indicates the use of direct references; but this is incorrect. A look at the AngularJS source code shows us that two-way data binding is implemented using a Scope.$watch() function. Which means that it is merely "eventually consistent."


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

To see this in action, I set up a small demo in which a Controller and an Isolate scope share the ability to update a "counter" value using two-way data binding. Both the Controller and the Isolate scope listen for an "increment event" and attempt to increment or decrement the current counter value. However, since the two-way data binding is "eventually consistent", you will quickly see that we run into a sort of race-condition within the digest lifecycle.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Isolate-Scope Two-Way Data Binding Is Eventually Consistent In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Isolate-Scope Two-Way Data Binding Is Eventually Consistent In AngularJS
  • </h1>
  •  
  • <!--
  • The bnIsolate directive uses two-way data binding to read from and write
  • to the "counter" scope value in the parent scope.
  • -->
  • <p bn-isolate="counter">
  •  
  • <a ng-click="triggerEvent()">Trigger Increment Event</a> &mdash;
  •  
  • <!-- This is the AppController scope reference. -->
  • Count: {{ counter }}
  •  
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.2.26.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 ) {
  •  
  • // I store the current counter, which is modifiable by both the current
  • // scope as well as the isolate scope (using two-way data bindings).
  • $scope.counter = 0;
  •  
  • // I handle the increment-value event by incrementing the value.
  • $scope.$on(
  • "IncrementValue",
  • function handleIncrementValue() {
  •  
  • $scope.counter++;
  •  
  • console.log( "Controller [post-increment]:", $scope.counter );
  •  
  • }
  • );
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I trigger the increment-value event which is being listened to by
  • // both the current scope as well as the isolate scope (which is using
  • // two-way data binding to mutate the counter as well).
  • $scope.triggerEvent = function() {
  •  
  • console.info( "Triggering increment event..." );
  •  
  • $scope.$broadcast( "IncrementValue" );
  •  
  • };
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I define an isolate scope that consumed a two-way data binding to a counter
  • // value that is passed-in via directive attributes.
  • app.directive(
  • "bnIsolate",
  • function() {
  •  
  • // I bind the JavaScript events to the local scope.
  • function link( scope, element, attributes ) {
  •  
  • // I handle the increment-event by DECREMENTING the local scope value.
  • // Since this value is part of a two-way data-binding, it MAY update
  • // the parent scope value as well... eventually.
  • scope.$on(
  • "IncrementValue",
  • function handleIncrementValue() {
  •  
  • console.log( "Isolate [pre-decrement]:", scope.localCounter );
  •  
  • scope.localCounter--;
  •  
  • console.log( "Isolate [post-decrement]:", scope.localCounter );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // Return the directive configuration; notice that we are setting up an
  • // isolate-scope which expects a counter to be passed in using two-way
  • // data bindings. This allows the directive to both respond to as well as
  • // precipitate changes in the calling scope.
  • return({
  • link: link,
  • restrict: "A",
  • scope: {
  • localCounter: "=bnIsolate"
  • }
  • });
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

Since the Controller is instantiated before the linking phase, our Controller will bind its event-handler before the isolate-scope directive binds an event-handler; however, both the Controller and the directive will be able to respond to the event in the same $broadcast(). This means that both scopes will receive the event before a digest is triggered (by the ng-click directive).

When we trigger this event a few times, we get the following console output:

(i) Triggering increment event...
Controller [post-increment]: 1
Isolate [pre-decrement]: 0
Isolate [post-decrement]: -1

(i) Triggering increment event...
Controller [post-increment]: 2
Isolate [pre-decrement]: 1
Isolate [post-decrement]: 0

As the Controller attempts to increment the counter, the isolate-scope directive attempts to decrement the counter. This quickly shows us two interesting behaviors of two-way data binding:

First, at the time that the isolate-scope goes to react to the "IncrementValue" event, the local scope value has not yet been synchronized by the two-way data binding. As such, the increment performed by the Controller is not yet available in the directive. So, when the directive goes to decrement the value, it's decrementing a pre-Controller value.

And, Second, the decremented value produced by the isolate-scope is never used. If you look at the console log, you'll see that the increment performed by the Controller results in a value that is 2 greater than the result of the isolate-scope decrement. This happens because AngularJS actually ignored the result of the isolate-scope event-handler.

This second behavior doesn't become symptomatic all the time - only when both the parent scope and the isolate-scope alter the shared value in the same digest. In this case, AngularJS gives precedence to the parent scope and ignores any changes made by the isolate-scope [to the same value]. So, in reality, the changes made by the isolate scope in this experiment are completely ignored.

The two-way data binding behavior of isolate-scopes allows directives to become more completely decoupled from their parent applications. But, this data binding is not magic - it's implemented using Scope.$watch(); and, understanding this will help you see that it is "eventually consistent," which will help you avoid unexpected behavior in strange edge-cases.




Reader Comments

Hey, thanks!, I good to know this..
I know the purpose of the post is not solving this edge case, but if if someone need it, here is a tip: if you take the content of the handleIncrementValue() {} inside the directive, and wrap it in a $timeout() every thing will work as expected..

Reply to this Comment

@Juan - You are correct. Just an explanation of why this is working: The above behaviour happens because both actions are taken in the same event loop / digest. When you put it inside a $timeout, you postpone it to the next event loop, working around the issue...

Reply to this Comment

@All,

The reality is probably that this is not a valid use-case. The whole point of the isolate scope is to decouple the directive from the application by forcing all things to go through the isolate scope. As such, it seems like you'd be violating the isolate-scope philosophy by binding to scope events... unless maybe the event-name was passed through the isolate scope.

But, I still think it's an interesting exploration because it forces you to think about the implementation of the isolate scope.

Reply to this Comment

I stumbled across another interesting implication related to this behavior. I was working on a simple search input directive. It has two scope values that are passed in, a model for storing the input and an onChange event function. The onChange event depends upon the input value on the parent controller. By placing an ng-change on the input it would attempt to execute the onChange function in the context of the parent controller before the binding was able to propagate back up.

By setting ng-change to a function that calls the onChange event in a $timeout the issue was resolved.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.