Isolate Scopes Explained

I've been using Angular for quite a while but isolate scope bindings is one thing that has continued to elude me. No longer. I finally had the lightbulb moment earlier this afternoon and thought I would put my thoughts into a comprehensive blog post. I've read a ton of articles online about the various bindings you can create on a directive's isolate scope. Although I was able to glean some information I always seemed to come out still somewhat confused. I'll start by explaining what an isolate scope is and then I'll dive into each of the isolate scope bindings (@, =, &) and explain each one in detail. I will try to keep things as simple and plain English as possible.

Note: This article assumes you understand what an Angular directive is. If you do not yet have a firm grasp on the concept of a directive, I recommend reading up on those first before coming back here.


Directive Scopes

By default a directive shares whatever scope was defined above it. For example:

<div ng-controller='mainController'>  
  <my-directive></my-directive>
</div>  
app.controller('mainController', function ($scope) {  
  $scope.foo = 'bar';
});
app.directive('myDirective', function () {  
  return {
    controller: function ($scope) {
    }
  };
});

In the above example the myDirective directive would share the scope created by the controller that surrounds it. You can see proof of that in this demo. The directive could access and modify $scope.foo at will without having to worry about the scope inheritance heirarchy because it is literally the exact same scope object. Not specifying the scope property in a directive is equivalent to specifying scope: false. Notice in the following demo that the parent label updates when you type in the input provided by the directive.

See the Pen ncrhL by Alex Ford (@Chevex) on CodePen.

In most directives the default configuration is not desired because the directive could override variables in the controller's scope and vice versa. If your directive is very specific to your application and you don't plan on sharing it with anyone else then perhaps this setup is fine for you, but for most other scenarios this just won't do. At the very least we'd usually like to keep the directive from polluting the parent scope. This is where scope: true comes in.

app.controller('mainController', function ($scope) {  
  $scope.foo = 'bar';
});
app.directive('myDirective', function () {  
  return {
    scope: true,
    controller: function ($scope) {
    }
  };
});

In this setup the directive gets its own scope that inherits prototypically from the parent scope. This is exactly the same as if you replaced the directive with a nested controller instead. Controller scopes inherit prototypically from each other by default. Notice in the following demo that modifying the input field in the controller's scope will modify the input provided by the directive, but it does not work the other direction.

See the Pen lEcAD by Alex Ford (@Chevex) on CodePen.

This is because before you type in the directive input there is no property on the directive's scope called foo. When it tries to read the property foo on the directive scope it's actually reading the inherited property from the prototype. As soon as you type in the directive input, it writes to property foo on the directive's scope. This new foo property on the directive scope essentially masks the inherited value from the parent scope. Checkout out this post if you're still confused about scope inheritance.

Okay, so what is an isolate scope?

So far we've seen how to create a directive with no scope of its own, and a directive whose scope inherits from the parent scope the way normal controller scopes do. An isolate scope is a scope that exists separately with no prototypal inheritance at all; a clean slate. To create an isolate scope it's as simple as setting the scope property to an empty object hash {}.

app.controller('mainController', function ($scope) {  
  $scope.foo = 'bar';
});
app.directive('myDirective', function () {  
  return {
    scope: {},
    controller: function ($scope) {
    }
  };
});

See the Pen utiKw by Alex Ford (@Chevex) on CodePen.

You can see now in the above example that the input field provided by the directive can no longer read the inherited value from the parent scope. It starts off blank. If you type in the field, the adjacent label is still updated because it is also reading from the same isolate scope. This is the most common type of scope for a directive because it prevents the controller from polluting the directive's scope with variables you might have thought had no value. If your directive expected a scope property to be undefined but it actually had an inherited value, it could potentially cause issues in your code.

So far isolate scopes are a pretty simple concept. That is, until you need one or more variables from the parent scope in your directive, but not all of them. When I was first learning about isolate scopes I assumed I could simply whitelist variables I wanted from the parent scope by specifying them on the object hash we passed to the scope property. I also assumed that the value of those properties would act as a default value for the scope variable in case the parent scope did not have such a variable specified.

app.directive('myDirective', function () {  
  return {
    scope: {
      foo: 'bar'
    },
    controller: function ($scope) {
    }
  };
});

I was wrong.

Your directive is an API.

To understand the properties that you set on your isolate scope hash in your directive you must first understand what your directive is. Think of directives like JQuery plugins. When you create a JQuery plugin you're usually thinking about how another user is going to consume your plugin. One of the first things to consider when building a plugin is how the user is going to pass in options that will customize it. Let's build a quick JQuery plugin that we can invoke on a textarea. After the user stops typing for 750 milliseconds it will automatically save the user's text to a URL that we specify. It will also notify us whenever the "saved status" changes via a callback function that we will provide.

See the Pen hyznH by Alex Ford (@Chevex) on CodePen.

Click over to the "JS" tab if you want to see the full plugin. The way we consume our plugin is the important part.

// Invoke our plugin and get reference to the returned API object.
var autosaveApi = $('#comment').autosave({  
  // Pass in our own custom save URL.
  saveUrl: 'http://codepen.io',
  // Pass in a callback to be invoked whenever the status changes.
  statusChanged: function (status) {
    $('#status').text(status.text).css('color', status.color);
  }
});

// Use the autosave API to query for the initial saved status and set our label.
var initialStatus = autosaveApi.getStatus();  
$('#status').text(initialStatus.text).css('color', initialStatus.color);

We pass in the URL we'd like it to save to when it fires, as well as a statusChanged callback that will be called whenever the plugin changes the status. This is how we are able to update the label to show "no changes", "changes pending...", etc. The plugin also returns a tiny API object with one method on it called getStatus. We call that method once to get the initial save status after instantiating the plugin. The point is that this is a very clear API that we are consuming. We are passing in options and interacting with the plugin where we need to. Directives function the same way.

The big difference between a directive and a JQuery plugin is that a directive is mostly controlled through markup instead of through JavaScript. Instead of passing an options hash into a plugin, we pass data through attributes on the directive. Instead of defining callback functions and manually invoking them from within the plugin, we setup two-way bound scope variables and expressions. Wouldn't it be cool if we could create an autosave comment box simply by dropping in an <autosave> element?

See the Pen sADBz by Alex Ford (@Chevex) on CodePen.

Already you can see that our directive code is quite a bit less complicated than its JQuery counterpart, but how we invoke it is the coolest part of all.

<autosave save-url="http://codepen.io" save-status="status"></autosave>  

What did we do here? A couple of things. First, we passed in a save-url attribute. This attribute's value becomes the value of the directive scope variable saveUrl. This is because of the way we setup our isolate scope using the @ syntax. The @ symbol tells angular that this is a one-way bound value. In other words, it simply took the value of the attribute and set it as the value of a scope variable with the same name. It becomes clearer what's going on if you specify a different name for your directive's attribute.

scope: {  
  saveUrl: '@saveEndpoint'
  saveStatus: '='
}
<autosave save-endpoint="http://codepen.io" save-status="status"></autosave>  

Notice that we included some text after the @ symbol in our isolate scope definition this time. This is the name of the attribute that the consumer of our directive should use. By default just specifying the @ means that the attribute name should be the same as the scope property name. This is just for convenience, but by specifiying a different attribute name you can start to see how it all comes together; we're defining an API for users of our directive, just like we did when we declared options in our JQuery plugin.

The second thing we did was pass in the save-status attribute. This attribute behaves a little differently from our save-url attribute. In the isolate scope definition we specified the = symbol which tells Angular that whatever is passed to the save-status attribute should be treated as a two-way bound variable. In this case it does not literally pass in the value "status" to become the value of saveStatus on the isolate scope the way saveUrl did. Instead, Angular looks on the parent scope for a variable called status and sets up some logic to make sure the value of that variable stays in sync with the saveStatus variable on your directive scope.

If you've ever used ng-model in your code before then you've already seen that in action. Pretend your isolate scope definition looks like this scope: { model: '=ngModel' } and that should clear things up. When you say ng-model="myObj" you're giving it a variable in your current scope named myObj to use as the value of model within the ngModel directive's own scope.

You may have noticed that we did not delcare a variable named status in the parent scope around our autosave directive; we didn't have to. Simply by passing it in as the value of save-status, when our directive sets the two-way bound variable to something it automatically propagates those changes to the status variable in our parent scope. Remember in our JQuery plugin how we had to provide an API so we could manually query for the initial save status of our plugin and set our label accordingly? We don't have to do that in Angular because as soon as the directive set the saveStatus variable internally, it was bound to the outer-scope variable called status which updated automatically, which in turn updated our <h3> label. Pretty great right?

Okay, I get all that. What about "&"?

Behold, the bane of my existence for the last few weeks, the & binding operator. I read dozens of articles about isolate scope bindings yet this one always seemed to escape my understanding. People kept saying things like "it allows you to evaluate an Angular expression blah blah blah" which only served to confuse me more. It wasn't until I took a look at ngClick's own source code that I finally had the lightbulb go on. You can find that code here but it's a little cryptic to read at first because it's looping through and creating directives for all the major DOM events. I re-wrote the code as if we were only defining the ngClick directive by itself to make it easier to read:

app.directive('ngClick', function () {  
  return {
    restrict: 'A',
    scope: {
      expression: '&ngClick'
    },
    link: function (scope, element) {
      element.on('click', function (e) {
        scope.$apply(function() {
          scope.expression({ $event: e });
        });
      });
    }
  };
});

If you looked at the original source then you saw them injecting a utility called $parse. All that utility does is allow you to parse an Angular expression, which is also what the & binding operator does. I know what you're thinking, what the hell does that mean? If you've used Angular for very long then you've probably written code like this:

<button ng-click="buttonClicked()">Click Me</button>  

Did you know that what you supplied to ng-click was an Angular expression? You can think of it as similar to JavaScript, but it's not quite the same. It doesn't suffer from the same nasty pitfalls that running random JavaScript code through eval does. Did you know you can supply multiple method invocations in the expression?

<button ng-click="buttonClicked(); doSomethingElse();">Click Me</button>  

You may also have had to pass in variables from your outer-scope into your expressions. For example, if you've ever created a delete button in an ng-repeat so you could delete a single item from a list then you may have written code like this:

<li ng-repeat="item in items">  
  <button ng-click="deleteItem(item)">Delete</button>
</li>  

Angular has to evaluate that expression and turn it into actual JavaScript code that can be executed. During that evaluation it knows what variables are in scope and is able to match the "item" argument in the expression to the item variable that is in scope due to the button being within the ng-repeat.

Have you ever had to deal with the $event object when invoking ng-click?

<button ng-click="doSomething($event)">Do Something</button>  

Where do you suppose that $event variable is coming from? If you check your local scope you'll find that it doesn't exist there, but you definitely have access to it when passing it into the function you've defined on your scope. You can call $event.stopPropagation(), $event.preventDefault(), all the goodies you're used to.

See the Pen dfCht by Alex Ford (@Chevex) on CodePen.

In the example above we access $event.type and show it in an alert with no problem. Most people think that ng-click is some special magic defined by angular that can inject special variables into the expression for DOM events. It's not magic and it's not even that special. You can do the exact same thing in your own directives. If we go back to my re-written example of the ngClick directive you can see exactly what they are doing. Here it is again but with comments to help clarify what's going on.

app.directive('ngClick', function () {  
  return {
    // Restrict this directive to attributes only.
    restrict: 'A',
    // Whatever is passed to the "ng-click" attribute
    // is an angular expression and that expression
    // will be stored on our isolate scope as "expression".
    scope: {
      expression: '&ngClick'
    },
    link: function (scope, element) {
      // "element" is the jQuery representation of the DOM
      // element that was clicked. We can simply call .on('click'
      // just like we would in jQuery.
      element.on('click', function (e) {
        // Because this handler can be invoked at any time
        // outside the Angular digest loop, we need to tell
        // Angular to track the logic about to happen so we
        // call scope.$apply.
        scope.$apply(function() {
          // Invoke our "expression" as a method. This triggers
          // Angular to evaluate the expression that was passed
          // in. Also, pass a hash with any variables this
          // directive has access to that the user of the
          // directive might need to use within the expression.
          // In this case, define a variable called "$event" and
          // set it to the jQuery event that we received on click.
          scope.expression({ $event: e });
        });
      });
    }
  };
});

Any lightbulbs come on yet? Here's a working example of my own custom click directive that works exactly like the built-in ngClick directive.

See the Pen dfCht by Alex Ford (@Chevex) on CodePen.

My code is functionally identical to the actual ngClick directive, I just changed a few things around to make it obvious that I'm in total control. For instance, I changed $event to myEvent just to show how it was injected into the expression when it was being evaluated; I was able to pass myEvent into my showEventType function that I created on my scope in my controller. That variable was exactly the same as Angular's $event variable and simply represented the JQuery click event.


I'm still not getting it...

A user on Reddit had this to say after reading this post:

I understand and appreciate the great effort to make things clear, but sadly, there is still some confusion:

When I see custom-click="showEventType(myEvent)", I want to know what myEvent is! But it is neither defined in the HTML, nor in the Controller. Even though it looks like a variable on the $scope.

I know, it replaces $event from Angular, but the latter is actually also confusing, still the $ tells me that there is some Angular's magic there, so I am alerted. Without it - I am looking for scope variables and don't find it.

Yes, I know - I can find them inside the directive, but the whole point of directive, esp. with isolated scope, is encapsulation, so I don't have to look there! In this respect, such directive is not well designed.

The main cause of confusion here, in my view, is that Angular's ng-click directive does two separate things:

  • Binds an expression to evaluate upon click;
  • Provides the event variable $event that becomes accessible in the Controller.

It can be much easier to focus on one thing only. So if we want the simplest example with &-binding, such example would only do the first thing. Something like that:

custom-click="showVariable(myVariable)"  ng-init="myVariable = 'I am a variable!' "

Which is very simple but actually not a good practice, so better put it in the Controller:

$scope.myVariable = 'I am a variable!';
$scope.showVariable = function(var) { alert(var); };

Now I know exactly what the expression does and don't have to look inside the directive!

I took some time to provide a detailed response. If you're having similar confusion to the user above, continue reading.

The $event variable doesn't become "accessible in the controller", it's only accessible in the expression during the evaluation of said expression. There's no simple in-app way to let you know what variables are available in an expression passed to a directive. I would need to let you know what variables your expression will have access to via documentation for my directive.

Here's a question for you:

$('#someElement').somePlugin({
  someCallback: function (???) {
    // ...
  }
});

How do you know what arguments are going to be passed to someCallback in the above hypothetical JQuery plugin? You don't. You either need to look at the plugin code, write some code to display all passed in arguments, or read the plugin's documentation. It's no different with a directive. The author of a public directive should be providing documentation for that directive, letting you know what variables are available in the expressions you are passing in.

Expressions also have access to any variables on the scope it is evaluated in (which is the scope around the directive you're passing it into). There are plenty of reasons why you might want access to an internal variable that only the directive has access to (such as the DOM event generated by event handlers set up within the directive). Just like how there are plenty of reasons why you might need a JQuery plugin to pass some internal variables to a callback you provide to the plugin.

This is what is going on inside an ngClick directive:

1) It sets up a click event handler on the element you put the ng-click directive on.

element.bind('click', function (clickEvent) {  
  // ...
});

2) When the DOM click event fires, evaluate the expression you passed in through ng-click. Example user code: ng-click="myFunction()".

element.bind('click', function (clickEvent) {  
  // Evaluate the expression passed in.
  scope.expression();
});

3) In case the user needs access to the clickEvent variable (which is otherwise only accessible inside the directive where the click handler was setup), pass it to the expression so the user can use it in their own code. Example user code: ng-click="myFunction($event)".

element.bind('click', function (clickEvent) {  
  // Evaluate the expression, defining some variables that the expression can use if needed.
  scope.expression({ $event: clickEvent });
});

4) Now in your own code you have access to the click event that was generated from within the directive.

function myController($scope) {  
  $scope.myFunction = function (e) {
    e.preventDefault();
    e.stopPropagation();
    // etc etc...
  };
}

Where else would you expect that DOM click event variable to come from? Since it was generated by an event handler that was setup inside the ngClick directive, there's no way your controller scope would have access to it. That's why the directive makes it available to your expression as $event. You would only know about the $event variable by either looking in the directive itself, or reading documentation for that directive.

The dollar sign on the $event variable is just an Angular convention letting you know that the variable was generated internally, meaning inside the ngClick directive. Don't get hung up on it. It's not magic. In my post I was showing how I could write a directive identical to ngClick and provide the same internal clickEvent variable to the expression as whatever variable name I feel like:

element.bind('click', function (clickEvent) {  
  // Evaluate the expression, defining some variables that the expression can use if needed.
  scope.expression({ domClickEvent: clickEvent });
});

With the above code you would now need to change your directive invocation to look like ng-click="myFunction(domClickEvent)". If I were providing that directive for others to use then I would be expected to document what variables are available within that expression the way Angular does for their own directives.

I think your confusion about not being able to see the variables defined within the directive comes from a misunderstanding of how it all works. You also cannot see variables defined within a JQuery plugin and there have to be conventions for exposing some of those variables to the consumer of the plugin/directive. In JQuery that's usually done via a callback function you pass in. In Angular that's done by exposing variables to the expressions you pass in. If you think about it, a callback function also has access to variables "in scope" in addition to the arguments passed in.

var someVarInScope = 'I am in the scope of your app.';  
$('#someElement').somePlugin({
  callback: function (arg1, arg2) {
    // I can access arguments passed in from the plugin such as arg1 and arg2.
    // I can also access someVarInScope, which I (the user) defined myself outside of the plugin.
  }
});

Is equivalent to:

<div ng-init="someVarInScope = 'I am in the scope of the current controller.'">  
  <button ng-click="myFunction(someVarInScope, $event)">Click Me!</button>
  <!-- I am a button whose click expression has access to someVarInScope. -->
  <!-- My expression also has access to the $event variable passed from the directive. -->
</div>  
function myFunction(someVarInScope, e) {  
  // I have one argument passed in that I defined myself: someVarInScope
  // I also have a second argument that was defined within the click directive: e
}

I hope this article helped to clear up the various isolate scope binding operators and gave you a better understanding of how directives work in general. If you have any constructive feedback then please feel free to leave a comment. I'm not perfect and I'm always on the lookout for mistakes I can fix and areas I can improve my understanding. Thanks for reading!

Chev

Read more posts by this author.

comments powered by Disqus