Transclusion and scopes

March 8, 2014   

The problem

There is a common misconception that I see when I am doing Angular support. My goal here is to address it.

Let’s imagine I have this simple controller and directive:

app.controller('MainCtrl', function($scope) {
  $scope.person = {
    name: 'John Doe',
    profession: 'Fake name'
  };
  
  $scope.header = 'Person';
});

app.directive('person', function() {
  return {
    restrict: 'EA',
    scope: {
      header: '='
    },
    transclude:true,
    template: '<div ng-transclude></div>',
    link: function(scope, element, attrs) {
      scope.person = {
        name: 'Directive Joe',
        profession: 'Scope guy'
      };
      
      scope.header = 'Directive\'s header';
    }
  };
});

I have a controller with a person and a header on the scope and I also have a person directive which also creates a person and modifies the header. The directive has an isolated scope, so it is not aware of the controller’s person. Let’s use it:

<body ng-controller="MainCtrl">
  <person header="header">
    <h2>{{header}}</h2>
    <p>Hello, I am {{person.name}} and,</p>
    <p>I am a {{person.profession}}</p>
  </person>
</body>

What is supposed to happen here? What should we see here? Let me think about it… We have a person directive which have a person on it called Directive Joe and also a header which says Directive's header. Then in our HTML we used the directive passing the controller’s header and then we put some HTML inside the directive. Alright, we should see the Directive's header and also the information about Directive Joe. That is obvious since the HTML inside the directive (which is called Transcluded html) is going to be transcluded into our directive. So our scopes are more or less like:

(The normal arrow is for new isolated scopes and the dashed is for new non-isolated scopes)

Try it

Angular tips


Wait a second, we have mixed result here… On the one hand, we have our Directive's header as expected but on the other hand, we got the controller’s John Doe person. That makes no sense at all. What’s going on?

The misconception here is to think that the transcluded html has access to the isolated scope or that the transcluded html is a new child scope of it (as I showed on the diagram before). The reality is that the transcluded html is a new child scope of the controller’s one. Yes, that is right:

(The normal arrow is for new isolated scopes and the dashed is for new non-isolated scopes)

Having this in mind, the result makes more sense. The transcluded html only sees what is on the controller’s scope. For the person it is clear, it is showing it as is. But what about the header? It is showing the directive’s one. Well, that isn’t true. Since we created a two-way databinding on the header, when we changed the header on the directive, the controller’s one also changed. That is why we saw Directive's header, because the controller’s header was also updated.

So, the transcluded html is a new child scope of the current scope on that DOM. In this case, the controller's scope. In the case that you put an ng-repeat like:

<body ng-controller="MainCtrl">
  <div ng-repeat="foo in foos">
    <person header="header">
      <h2>{{header}}</h2>
      <p>Hello, I am {{person.name}} and,</p>
      <p>I am a {{person.profession}}</p>
    </person>
  </div>
</body>

The scopes would be like:

(The normal arrow is for new isolated scopes and the dashed is for new non-isolated scopes)

The ways around

This is how the transclusion and its scope works by default. That doesn’t mean that we can’t do something to modify this behavior.

If we check the documentation we can see that the link function of a directive is like:

function link(scope, iElement, iAttrs, controller, transcludeFn) { ... }

Uh, that fifth parameter says something about transclusion. With that function, we have control of both the scope and the HTML of the transclusion. Let’s see it:

app.directive('person', function() {
  return {
    restrict: 'EA',
    scope: {
      header: '='
    },
    transclude:true,
    link: function(scope, element, attrs, ctrl, transclude) {
      scope.person = {
        name: 'Directive Joe',
        profession: 'Scope guy'
      };
      
      scope.header = 'Directive\'s header';
      transclude(scope.$parent, function(clone, scope) {
        element.append(clone);
      });
    }
  };
});

NOTE: Link parameters are fixed parameters so doesn’t matter the name you give to them.

the transclude function receives a function and an optional first parameter. What this function does is to clone the transcluded html and then you can do with it what you want. If you put a scope as the first parameter, that scope will be the one used on the cloned element. The callback function of transclude will receive the cloned DOM and also the scope attached to it.

In this case, we are using the directive’s parent scope (in this case the controller’s one) as the scope of the transcluded html and then we are receiving it in the callback function. What we do here is just append it on our directive’s DOM element. In the case we had a template on the directive, we could retrieve a DOM element and then use it to append the transcluded html, that is what I call complete control :)

Try it

Angular tips


The result is the same visually, but internally the transclusion did not create a new scope, it is using the controller’s one.

(The normal arrow is for new isolated scopes and the dashed is for new non-isolated scopes)

On the other hand, you can get the behavior you expected when you opened this article, that is the transcluded html using the isolated scope. I know you smart and you figured it out, but there it is:

app.directive('person', function() {
  return {
    restrict: 'EA',
    scope: {
      header: '='
    },
    transclude:true,
    link: function(scope, element, attrs, ctrl, transclude) {
      scope.person = {
        name: 'Directive Joe',
        profession: 'Scope guy'
      };
      
      scope.header = 'Directive\'s header';
      transclude(scope, function(clone, scope) {
        element.append(clone);
      });
    }
  };
});

Try it

Angular tips


Now it is using the isolated scope as the transcluded html scope.

Take in mind that maybe the people that will consume your directives are not aware that you’re tweaking the transcluded scope, so if you use it, be sure you document it well.

comments powered by Disqus