Communication between collaborating directives in Angular

Posted on by in Development

Directives man! While the they’re literally the entry point into Angular development (every application contains a call to ng-app), many people starting out with Angular are hesitant to write their own because of the complexity associated with them. And directives are complex. Even a simple directive requires understanding complicated concepts like restrict, scope and link. But when you figure it out, the value of a directive as a reusable component becomes indispensable.

But what about when a directive isn’t self contained? What if a complicated component comes along that is naturally modeled by multiple directives? This group of directives, as a whole, form a single self contained component. None of directives in the group can stand alone because they only make sense when used together; they collaborate; they are aware of each other and need to communicate with each other.

This post will discuss best practices for managing communication among collaborating directives and illustrate these practices with an example.

Imagine a scenario like this:

<parent-component>
  <child-component></child-component>
  <another-child-component></another-child-component>
</parent-component>

Let’s assume that when something changes in child-component that parent-component and another-child-component need to know about it. How should these directives communicate? There is an answer tucked away in the Angular documentation, but before looking at it let’s survey a set of approaches that could be taken with the tools at hand.

Shared scopes

It’s tempting to communicate via scopes. parent-component could expose an API by exporting functions and data on its scope. This API is available to both child-component and another-child-component, assuming neither uses an isolate scope. Now both directives can communicate directly with their parent via the API on scope. However, parent-component cannot communicate directly with child-component or another-child-component because scopes represent a hierarchical relationship between parent and child. This relationship is implemented using prototypical inheritance, so children have access to data placed on the scope by the parent but not vice versa. parent-component has to find another way to communicate with its children.

Similarly, child-component and another-child-component will have trouble communicating because they are siblings and scopes do not provide a method for sibling communication.

This approach does not appear to be ideal. Communication is limited, but there are a few other problems:

  • Scopes are generally used to provide data to templates and coordinate events generated by user interaction with the view. Using them for inter-directive communication concerns overloads the responsibilities of scope. This is working against the framework.
  • Isolated scopes are not available since data and communication is being routed through scopes.
  • As parent-component grows, so too will the surface area of scope. It will become hard to maintain and reason about the implementation. Data placed on the scope by parent-component may accidentally be masked by data placed on the scope by child-component or another-child-component.

If scopes aren’t your jam, check out this article on our blog for a more detailed discussion on scopes in angular.

Eventing via $scope.$broadcast, $scope.$emit & $scope.$on

Because communication between parent and child or between sibling directives is limited via scopes, another option is Angular’s eventing system. Because this approach does not rely on scope (directly at least) for communication none of the problems outlined above will present themselves. But the design of the eventing system presents its own challenges.

Events in Angular are directional. They either travel up the scope hierarchy via $scope.$emit or down it via $scope.$broadcast. This means that the location of a directive in the document is important when it comes to sending the event. While this can be circumvented by injecting $rootScope and only sending events down the chain using $rootScope.$broadcast, the extra dependency is a bit of a bummer. An increasing dependency list size is usually an indication that something is amiss.

The biggest problem with this approach, conceptually at least, is that the collaborating directives are carrying on a private conversation in public. By using $broadcast, the event is delivered across the entire system and any directive can react to it. This can lead to unintended consequences, e.g. a generically named event triggers an unintended event handler. This problem can be solved by naming conventions, but this is a work around attempting to better target the event. $broadcast is best suited to situations where the type and number of listeners for an event is unknown by the origin of the event. This differs from the situation of collaborating directives; the consumers are well known.

Controllers

So how should collaborating directives communicate? The answer is in the documentation for directives (tucked away at the bottom of the page):

Best Practice: use controller when you want to expose an API to other directives. Otherwise use link.

Obvious, right? Ok, maybe not. Taking a look at how a directive is defined will shed some light. Consider, again, this example template:

<parent-component>
  <child-component></child-component>
  <another-child-component></another-child-component>
</parent-component>

And this directive definition for parent-component:

module.directive('parentComponent', function() {
  function ParentComponentController(scope) {
    // initialize scope
  }

  ParentComponentController.prototype.doSomething = function() {
    // ironically does nothing
  }

  return {
    restrict: 'E',
    controller: ['$scope', ParentComponentController],
    scope: {}
  };
});

The parent-component directive specifies it provides a controller ParentComponentController. The controller is a class and is instantiated each time the directive is encountered. It is instantiated before the directive’s template is compiled so that the controller can initialize the scope if needed.

Now look at the directive definition for child-component:

module.directive('childComponent', function() {
  function link(scope, element, attrs, controller) {
    controller.doSomething();
  }

  return {
    restrict: 'E',
    require: '^parentComponent',
    link: link,
    scope: {}
  }
});

There are two things at play here. child-component specifies via the require property that it needs the controller provided by parent-component. Controllers are referenced not by the name of the controller class, but by the name of the directive as registered with the dependency injector. The ‘^’ indicates that the controller is provided by a parent directive. As a result of requiring the parent-component controller, it is passed to child-component‘s link function as the fourth argument.

That covers the basic mechanics of inter-directive communication. The remainder of this article will focus on an example. If you’d like to follow along, keep reading. Otherwise the source for the example is available here.

An Example

It should behave like this:

  • By default, annotations are not shown.
  • The total number of annotations is shown
  • There is a control to show annotations.
  • Pressing the “show annotations” control overlays the annotations on the image.
  • The “show annotations” control is replaced by a “hide annotations” control after it is pressed.
  • Annotations can be selected by clicking on their visual representation in the image.
  • Selecting an annotation will show the text and author of the annotation.
  • Pressing “hide annotations” control returns the component to its original state.

As a starting point, consider what the template for this component might look like:

<div class="annotations-control">
  <span ng-click="showAnnotations()" ng-hide="viewing">Show</span>
  <span ng-click="hideAnnotations()" ng-show="viewing">Hide</span>
  <span ng-click="showAnnotations()">{{ annotations.length }} Annotations</span>
</div>

<canvas></canvas>

<div class="annotation-content" ng-if="viewing">
  <div ng-if="annotation">
    <span>"{{ annotation.text }}"</span> - <span>{{ annotation.author }}</span>
  </div>
</div>

The canvas tag will be where the image and the annotations are dispayed. The div with class annotations-control represents the controls area for showing and hiding. The div with class annotation-content is the text of the currently selected annotation. Unfortunately, the template does not specify where the image source and the annotations are coming from. This means anybody using this code will also have to look in the javascript to fully know how this component is wired up.

The canvas and the two divs represent distinct visual components and make good candidates for directives. Let’s update the template to only show the higher level directives:

<annotated-image-controls annotations="annotations"></annotated-image-controls>
<annotated-image-viewer src="image" annotations="annotations"></annotated-image-viewer>
<annotated-image-current></annotated-image-current>

This looks better. Before it was unclear where the image source and annotations came from, now it is expressed in the template. But, this is a lot of boilerplate to use this component. Taking that as a hint, let’s expose a single directive to coordinate the whole thing:

<annotated-image configuration="config"></annotated-image>

The definition of annotated-image looks like this:

angular.module('annotated-image').directive('annotatedImage', function()
{
  function AnnotatedImageController(scope) {}

  return {
    {
      restrict: 'E',
      template: [
        '<annotated-image-controls annotations="configuration.annotations"></annotated-image-controls>',
        '<annotated-image-viewer src="configuration.image" annotations="configuration.annotations"></annotated-image-viewer>',
        '<annotated-image-current></annotated-image-current>'
      ].join('\n'),
      controller: ['$scope', AnnotatedImageController],
      scope: {
        configuration: '='
      }
    }
  };
});

The main responsibility of annotated-image is to coordinate annotated-image-viewer, annotated-image-controls and annotated-image-current. It provides a template which includes them and a controller to facilitate communication between them. The controller will be built up as the other three directives are developed.

Let’s focus on the task of showing annotations. Remember that clicking the “show annotations” control should cause changes in all three directives. Here are their definitions:

angular.module('annotated-image').directive('annotatedImageControls', function() {
  function link(scope, el, attrs, controller) {
    scope.showAnnotations = function() {
      controller.showAnnotations();
    };

    controller.onShowAnnotations(function() {
      scope.viewing = true;
    });
  }

  return {
    restrict: 'E',
    require: '^annotatedImage',
    template: [
      '<div>',
        '<span span[data-role="show annotations"] ng-click="showAnnotations()" ng-hide="viewing">Show</span>',
        '<span span[data-role="hide annotations"] ng-click="hideAnnotations()" ng-show="viewing">Hide</span>',
        '<span ng-click="showAnnotations()">{{ annotations.length }} Annotations</span>',
      '</div>'
    ].join('\n'),
    link: link,
    scope: {
      annotations: '='
    }
  };
});
angular.module('annotated-image').directive('annotatedImageViewer', function() {
  function link(scope, el, attrs, controller) {
    var canvas = el.find('canvas');
    var viewManager = new AnnotatedImage.ViewManager(canvas[0], scope.src);

    controller.onShowAnnotations(function() {
      viewManager.showAnnotations(scope.annotations);
    });
  }

  return {
    restrict: 'E',
    require: '^annotatedImage',
    template: '<canvas></canvas>',
    link: link,
    scope: {
      src: '=',
      annotations: '='
    }
  };
});
angular.module('annotated-image').directive('annotatedImageCurrent', function() {
  function link(scope, el, attrs, controller) {
    controller.onShowAnnotations(function() {
      scope.viewing = true;
    });
  }

  return {
    restrict: 'E',
    require: '^annotatedImage',
    link: link,
    template: [
      '<div class="annotation-content" ng-if="viewing">',
        '<div ng-if="annotation">',
          '<span>"{{ annotation.text }}"</span> - <span>{{ annotation.author }}</span>',
        '</div>',
      '</div>'
    ].join('\n'),
    scope: { }
  };
};

Notice annotated-image-controls, annotated-image-viewer and annotated-image-current require the controller provided by annotated-image. Also notice that the fourth argument to the link function is the required controller. Remember that the ‘^’ at the beginning of the require property means the controller will be provided by a parent directive. This effectively means that these directives cannot be used outside of annotated-image because Angular will throw an error when it cannot locate the required controller.

annotated-image-controls exports a function called showAnnotations which is bound via ng-click to span[data-role="show annotations"]. The showAnnotations function simply delegates to AnnotatedImageController.

When AnnotatedImageController#showAnnotations is called it notifies interested listeners of the event. This is implemented using a simple observer pattern for flexibility:

function AnnotatedImageController(scope) {
  this.handlers = {
    showAnnotations: []
  }
}

AnnotatedImageController.prototype.showAnnotations = function() {
  this.handlers.showAnnotations.forEach(function(handler) {
    handler();
  });
}

AnnotatedImageController.prototype.onShowAnnotations = function(handler)
{
  this.handlers.showAnnotations.push(handler);
}

All three directives register themselves with AnnotatedImageController#onShowAnnotations because all three must take action when a request to show annotations is made. annotated-image-controls toggles it’s state. annotated-image-current reveals itself. annotated-image-viewer redraws the image with the annotations overlaid. The “hide annotations” functionality can be implemented in much the same way.

Summary

Directives are complicated. Directives that need to collaborate are even more complicated. Having an understanding of the tools Angular provides in this scenario can help you manage and contain this complexity within your application.