Click here to Skip to main content
15,884,388 members
Articles / Programming Languages / ECMAScript

Communicating between AngularJS Directive and Parent Controller

Rate me:
Please Sign up or sign in to vote.
5.00/5 (1 vote)
22 Oct 2021MIT17 min read 7K   35   5  
This tutorial will discuss three different ways of communication between parent controller and AngularJS directive.
In this tutorial, you will see three different ways to facilitate the communications between AngularJS directives and the parent controller. These three approaches have their own issues. They can create tight coupling between parent controller and directive. But if one can plan these carefully, such problems can be minimized.

Introduction

A common issue which almost all programmers would face when they work extensively with AngularJS is the question of how to effectively conduct the communications between an AngularJS directive and the parent controller. One way to do this is using the "broadcast" and "emit" mechanism. That is what I have done and I can tell you that it was a terrible idea. The performance of such mechanism can be questionable. Another problem is that sometimes, the parent controller can broadcast some message long before the directive had the chance of getting properly initialized, and missed the broadcast and creating some weird errors.

For me, the major problem was performance, especially, when there are a lot of data involved. And these mechanisms of "broadcast" and "emit", sounded simple and looked straight forward (and they are). But those are traps. It is easiest for beginners to get caught in these simple/straight forward mechanisms, and mess up a big project. Imagine that you have an AngularJS project with lots of directives or components and a lot of complex communications between those and their parent controllers. Then some inexperienced programmer decided to use the "broadcast" and "emit" mechanisms. After the project is completed, lots and lots of customers complained about performance, and weird application behaviors. My advice, don't get stuck in such a situation.

So the question, is there a better way? Of course there is. In this tutorial, I will discuss three different ways to facilitate the communications between AngularJS directives and the parent controller. The first would be the directive uses a watcher to monitor the data change in the parent controller and make the appropriate move. The second one is how AngularJS directive can invoke methods of parent controller. This can be done in two different ways. These makes three different approaches, to ensure communications between two can be done as efficiently as possible, and without all the problems related to "broadcast" and "emit". But these three approaches have their own issues. They can create tight coupling between parent controller and directive. But if one can plan these carefully, such problems can be minimized.

Sample Application Architecture

To demonstrate these approaches I described above, I have added a sample AngularJS application. It is a simple class registration. User can enter the student name, student age, and course name. Then click on a button. The course will be displayed in a list. The list displayed in the same page is an Angular directive, which has its own controller (the child controller). The section where the user can enter the registration is in the main application controller (the parent controller). On the course registration list, each row has two buttons, one is to send the course registration info of the row back to the parent controller, the other button is signaling the parent controller that a course registration should be deleted. The question here is why parent controller cares a course registration is deleted? The reason is that it is convenient to keep the master list of all course registrations in the parent controller, and the directive's sole responsibility is displaying the list, not managing the master data list. As you can see, it is important for the two to have efficient communication.

So here is how I divide the responsibility, the directive is responsible to display any available course registrations. Any list data manipulation is done in the parent controller, such as adding a new registration, or deleting one. Anytime the course registration list has changed, the directive must be notified to refresh the list. But there is the problem where each of the data rows in the directive have the actions of deleting the row, this is a manipulation of the data list, which should be done by the parent controller. And viewing the data of the row, it can be the responsibility of the directive or it can be the responsibility of the parent controller. To demo the communication back to the parent controller, I left such responsibility to the parent controller. This sample application will also show how to pass a method from the parent controller to the directive, and directive can call with passing parameters. This is yet another way directive can pass data to the parent controller.

The client side coding is done with EMCA script version 6 (as best as I could). So I will be using modules, imports, and all the good stuff to demo how to write an AngularJS directive and hook it up with the application controller (the parent controller). If you want to learn how, this is a great tutorial of it.

Image 1

The Main Application Module

The sample application is a single page web application. It uses Spring Boot to create a web server, just to serve the HTML page and associated JavaScript files. There is nothing to it. All the important parts are with the application JavaScript files. I will start with the easiest part -- the application module file.

This is my main application module file, looks like this:

JavaScript
import { TwoLayersController } from '/assets/app/js/TwoLayersController.js';
import { CourseListController } from '/assets/app/js/CourseListController.js';
import { courseListDirective } from '/assets/app/js/CourseListDirective.js';

let app = angular.module('startup', []);
app.controller("CourseListController", [ "$rootScope", "$scope",  CourseListController ]);
app.directive("courseList", [ courseListDirective ]);
app.controller("TwoLayersController", TwoLayersController);

I was using ECMA6 Script syntax for this. So it is different from the old way of writing code with JavaScript. The first three lines are importing some objects from other JavaScript files. The first object I have imported is called TwoLayersController. This is the main controller of this application. The second line imports an object called CourseListController. This is the controller used by the directive. The third line imports a function called courseListDirective. This function is used to define the directive. The next 4 lines define the bootstrap of this AngularJS application. The first of these 4 lines is defining the AngularJS module. The next line is to register controller used by the directive. The next line is registering the directive. I call this directive "courseList". You will see it being used on the web page. The last line here is registering the main controller. The whole thing seems strange. But it should be all too familiar. All these can be found in the file "app.js".

Next, I will show you how the directive is defined. To define a directive. All you need is a function that returns an object defining how the directive should behave. I personally like a directive with isolated scope. There is an advantage of doing this, it allows the same directive to be used by the same parent controller multiple times.

Defining the Directive

In order to create a directive with isolated scope, I need a function and a controller specific to the directive. This is the function that returns the specification of the directive:

JavaScript
export function courseListDirective () {
   return {
      restrict: "EA",
      templateUrl: "/assets/app/pages/courseList.html",
      scope: {
         allItemsInfo: "=",
         callHostMethod: "&"
      },
      controller: "CourseListController",
      controllerAs: "courseList"
   };
}

This is a simple definition of the directive. It defines the directive to be used as an HTML element or an attribute of an element (the line "restrict"); the HTML page template for the directive mark up (the line "templateUrl"); the line that has "scope" defines the data or methods to be passed into this directive. This line not only creates the isolated scope, it also can be utilized to create the mechanism for the two way communication. The last two lines define the controller which this directive is associated with. Here, I passed name of the controller "CourseListController". You might ask, how this works? The answer is dependency injection. The controller with the name "CourseListController" has been registered in the application module (app.js). Here, the directive definition would be able to get a reference of the actual controller with that name. And the controllerAs specifies the scope object name for the HTML mark up.

This is the easy part, the controller used by the directive is a little complex. Here is the whole source code file:

JavaScript
export class CourseListController {
   constructor($rootScope, $scope) {
      this._scope = $scope;
      this._rootScope = $rootScope;
      
      this._itemsList = null;
      this._callbackObj = null;
      this._callHostMethod = null;
      
      if (this._scope.callHostMethod) {
         this._callHostMethod = this._scope.callHostMethod;
      }
      
      let self = this;
      this._scope.$watch("allItemsInfo.lastUpdatedTime", function(newValue, oldValue) {
         if (newValue && newValue.trim() !== "" && newValue !== oldValue) {
            if (self && self._scope && 
            self._scope.allItemsInfo && self._scope.allItemsInfo.allItems) {
               self._itemsList = angular.copy(self._scope.allItemsInfo.allItems);
               self._callbackObj = self._scope.allItemsInfo.callbackObj;
            } else {
               // XXX something not right, you might want to throw an exception.
               console.log("Something is not right about the items list 
                            from the host controller.");
               self._itemsList = null;
               self._callbackObj = null;
            }
         }
      });
   }
   
   get itemsList () {
      return this._itemsList;
   }
   
   set itemsList (val) {
      this._itemsList = val;
   }
   
   get callbackObj () {
      return this._callbackObj;
   }
   
   set callbackObj (val) {
      this._callbackObj = val;
   }
   
   callHostMethod() {
      if (this._callHostMethod) {
         let paramData = {
            title: "Jimmy Sings",
            message: "Jimmy is Jimi Hendrix."
         };
         this._callHostMethod({ msgData: paramData });
      }
   }
}

The two ways for this directive to communicate with its parent controller. The first way is getting a reference to the parent controller from the object passed in during directive definition, then I can use anything from the parent controller as long as it is publicly accessible. The other way is to invoke the method of the parent controller given to the directive.

Let me explain the first way. In the above source code piece, I have passed in a data object called allItemsInfo. The way I define it, any change to it the directive can see the change and the parent controller can also see the change. This is all done by the $scope.$apply(). It gets particularly tricky when such object is an array. Sometimes, the parent controller changed the array reference to a new one, and suddenly the directly is no longer functioning. This is because the directive is still expecting the changes to be happening to the old array. This is why you should never pass array as a two way notifiable object to a directive. Instead, wrap it in an object, and make sure the parent controller and the directive controller both have the same reference of the object. This way, you can change the array reference to any array and as soon as you have the right monitoring established, any changes to this object's properties can be instant for both parties. This is the most effective way for directive and parent controllers to communicate with each other.

In the parent controller, which is in the file "TwoLayersController.js", defines this scope object, called this._itemsbag. This is the wrapper object for the array/list for course registrations. The definition looks like this:

JavaScript
this._itemsBag = {
   allItems: [],
   lastUpdatedTime: null,
   callbackObj: null
};

This object allows me to manipulate the array/list without the fear that the array/list reference value being changed between the directive and its parent controller. In addition, this object also allows changes to be observed, and corresponding actions can be taken. This is done with a watcher on the directive side. The idea is that When the parent controller manipulates the array/list, the directive gets notified to refresh its own list, which is displayed to the user. This is done as the following, creating a watcher, and using it to refresh the internal list of the directive:

JavaScript
let self = this;
this._scope.$watch("allItemsInfo.lastUpdatedTime", function(newValue, oldValue) {
   if (newValue && newValue.trim() !== "" && newValue !== oldValue) {
      if (self && self._scope && self._scope.allItemsInfo && 
          self._scope.allItemsInfo.allItems) {
         self._itemsList = angular.copy(self._scope.allItemsInfo.allItems);
         self._callbackObj = self._scope.allItemsInfo.callbackObj;
      } else {
         // XXX something not right, you might want to throw an exception.
         console.log("Something is not right about the items list from the host controller.");
         self._itemsList = null;
         self._callbackObj = null;
      }
   }
});

Let me explain, the object I have created has three properties:

  • The array that contains the course registrations.
  • A time stamp, indicating when this object was last updated. This is the property that the watcher is watching.
  • A callback object which the directive can use to communicate back to. I will discuss more about this.

In the above definition of the watcher, it is setup to check the last updated timestamp of the object. Anytime it detects a change of value, it will do a copy of the list in the objects and pass to the local list. It also passes the callback object to directive. To monitor the change in timestamp:

JavaScript
let self = this;
this._scope.$watch("allItemsInfo.lastUpdatedTime", function(newValue, oldValue) {
   if (newValue && newValue.trim() !== "" && newValue !== oldValue) {
   ...
   }
});

This is the code that copies the changed data from the object to the directive:

JavaScript
if (self && self._scope && self._scope.allItemsInfo &&
    self._scope.allItemsInfo.allItems) {
   self._itemsList = angular.copy(self._scope.allItemsInfo.allItems);
   self._callbackObj = self._scope.allItemsInfo.callbackObj;
} else {
   // XXX something not right, you might want to throw an exception.
   console.log("Something is not right about the items list from the host controller.");
   self._itemsList = null;
   self._callbackObj = null;
}

To make this simple, I am skipping a lot of checks and error handling. Now that we know how to signal directive about data change from the parent controller, I will show you how to signal back to the parent directive. For the course list, I want to send back the info of a selected course registration to the parent controller so that the parent controller can display it on its page. And I also want to delete a selected item from the list in the directive. As you can see from above code, there is no logic for how this is done in it. This is because the handling of viewing a course registration or deleting a course registration is happening on the HTML side:

HTML
<td class="text-center">
   <button class="btn btn-default btn-sm"
         title="View Info"
         ng-click="courseList.callbackObj && 
         courseList.callbackObj.showRegistrationInfo(itm)">
      <i class="glyphicon glyphicon-zoom-in"></i></button>
   <button class="btn btn-default btn-sm"
         title="View Info"
         ng-click="courseList.callbackObj && 
                   courseList.callbackObj.deleteRegistrationInfo(itm)">
      <i class="glyphicon glyphicon-trash"></i></button>
</td>

The list display is done with an HTML table. The first column has two buttons, one is for viewing the registered course info. The other is for deleting the registered course info. The two buttons have their ngClick even handlers defined. The way they are defined is to check if the event handlers exists, then invoke it. And if you read this carefully, you can see that the event handler is part of the callback object, which is the parent controller. The handler methods are part of parent controller. This is how the directive references methods of its parent controller and invokes them. This is just one way. There is another way, basically you can pass in the methods from the parent controller, save them as references in the directive. Then use the references to invoke them.

The hard part invoking reference of the methods from parent controller is the problem of how to pass in parameters to the methods. Turned out, it was pretty easy to do once I figured out the syntax. One thing I dislike about AngularJS is the syntax, and the inconsistency associated. Let me show how this is done. First, let's look at how this directive is added to the HTML page. The HTML page is named "index.html", in it, there is this line:

HTML
...
<div course-list all-items-info="vm.itemsBag" 
 call-host-method="vm.receivedMessageShow(msgData)" ></div>
...

This is a div, it has the first attribute of course-list. This attribute is the directive I have defined. The name of the directive is "courseList". When it is used in HTML, the camel case is transformed to "course-list". This is the AngularJS convention. The div element also has two more attributes, the first is called all-items-info. This is to pass in the wrapper objects of all course registrations list and associated properties. The second one is called call-host-method. It is used to pass in the parent controller method so that the directive can invoke it. As you can see, the method that is passed in is a signature, like this: "vm.receivedMessageShow(msgData)". This means the method when invoked, must have a parameter passed in.

If we get into the directive's controller, this method passed in can be invoked as the following:

JavaScript
...
callHostMethod() {
   if (this._callHostMethod) {
      let paramData = {
         title: "Jimmy Sings",
         message: "Jimmy is Jimi Hendrix."
      };
      this._callHostMethod({ msgData: paramData });
   }
}
...

In my directive controller, I have this method that just invokes the method from the parent controller, to demo how it can be done with a parameter passed in. As shown, to do so, I have to write the parameter in an object, and the only property for this object has the same name as the parameter for the method to be invoked. In the directive usage in HTML, the parameter for the method is called "msgData". So parameter wrapper object is defined as:

JavaScript
...{ msgData: paramData }...

The actual invocation is like this:

JavaScript
...
this._callHostMethod({ msgData: paramData });
...

I hope you get what is going on. The next question is, what does this method do in the parent controller? All it does is display an alert message box:

JavaScript
...
receivedMessageShow(msgData) {
   if (msgData) {
      alert("Title: " + msgData.title + "; Message: " + msgData.message);
   }
}
...

You can see the definition of this method in the file TwoLayersController.js.

The Parent Controller

I have covered all the important parts, I will show you what the parent controller looks like:

JavaScript
export class TwoLayersController {
  
   constructor() {
      this._studentName = "";
      this._studentAge = 0;
      this._courseName = "";
      
      this._itemsBag = {
         allItems: [],
         lastUpdatedTime: null,
         callbackObj: null
      };
   }
   
   set studentName(val) {
      this._studentName = val;
   }
   get studentName() {
      return this._studentName;
   }
   
   set studentAge(val) {
      this._studentAge = val;
   }
   get studentAge() {
      return this._studentAge;
   }
   
   set courseName(val) {
      this._courseName = val;
   }
   get courseName() {
      return this._courseName;
   }
   
   set itemsBag(val) {
      this._itemsBag = val;
   }
   get itemsBag() {
      return this._itemsBag;
   }

   addCourseRegistration() {
      // I will assume all the inputs are valid.
      // This is the place for input validation. Just a thought.
      let itemToAdd = {
         studentName: this._studentName,
         studentAge: this._studentAge,
         courseName: this._courseName
      };
      
      if (this._itemsBag == null) {
         this._itemsBag = {
            allItems: [],
            lastUpdatedTime: null,
            callbackObj: null
         };
      }
      
      this._itemsBag.allItems.push(itemToAdd);
      this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
      this._itemsBag.callbackObj = this;
      
      this.clear();
   }
   
   clear() {
      this._studentName = "";
      this._studentAge = 0;
      this._courseName = "";
   }
   
   showRegistrationInfo(item) {
      console.log("callback from the directive - show item info.");
      if (item) {
         this._studentName = item.studentName;
         this._studentAge = item.studentAge;
         this._courseName = item.courseName;         
      }
   }
   
   deleteRegistrationInfo(item) {
      console.log("callback from the directive - delete item from list.");
      if (item) {
         if (this._itemsBag && this._itemsBag.allItems && 
             this._itemsBag.allItems.length > 0) {
            let newRegList = [];
            angular.forEach(this._itemsBag.allItems, function (itemToCheck) {
               if (itemToCheck &&
                  (itemToCheck.studentName !== item.studentName ||
                   itemToCheck.studentAge !== item.studentAge ||
                   itemToCheck.courseName !== item.courseName)) {
                  newRegList.push(itemToCheck);
               }
            });
            
            if (newRegList && newRegList.length > 0) {
               this._itemsBag.allItems = newRegList;
               this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
               this._itemsBag.callbackObj = this;
            } else {
               this._itemsBag.allItems = [];
               this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
               this._itemsBag.callbackObj = this;
            }
         }
      }
   }
   
   receivedMessageShow(msgData) {
      if (msgData) {
         alert("Title: " + msgData.title + "; Message: " + msgData.message);
      }
   }
}

Let's begin with the constructor. The constructor defines three model properties. They are for the input fields of the page so that user can enter a course registration (student name, student age and course name). It also defines an object with a list, last updated date time, and a callback reference to this controller. This is the object that will be passed to the directive. This object serves as a mean of communication between the two, which I have already explained before. This is the constructor of this controller class:

JavaScript
constructor() {
   this._studentName = "";
   this._studentAge = 0;
   this._courseName = "";

   this._itemsBag = {
      allItems: [],
      lastUpdatedTime: null,
      callbackObj: null
   };
}

I also defined a series of getters and setters for all the data model properties for this controller. They are needed because on the HTML page, I reference them like this vm.studentName. not like this vm._studentName. The reference can be done by getters and setters. Here they are:

JavaScript
...
   set studentName(val) {
      this._studentName = val;
   }
   get studentName() {
      return this._studentName;
   }
   
   set studentAge(val) {
      this._studentAge = val;
   }
   get studentAge() {
      return this._studentAge;
   }
   
   set courseName(val) {
      this._courseName = val;
   }
   get courseName() {
      return this._courseName;
   }
   
   set itemsBag(val) {
      this._itemsBag = val;
   }
   get itemsBag() {
      return this._itemsBag;
   }
...

The method called "addCourseRegistration()" will take the value of the input fields and create a small object of course registration and add to the list. While adding an object to the destination list, the last updated date time is set to the latest timestamp. This will trigger the directive to update its own list. This is how the parent controller communicates to the directive. Here is the method addCourseRegistration():

JavaScript
addCourseRegistration() {
   // I will assume all the inputs are valid.
   // This is the place for input validation. Just a thought.
   let itemToAdd = {
      studentName: this._studentName,
      studentAge: this._studentAge,
      courseName: this._courseName
   };

   if (this._itemsBag == null) {
      this._itemsBag = {
         allItems: [],
         lastUpdatedTime: null,
         callbackObj: null
      };
   }

   this._itemsBag.allItems.push(itemToAdd);
   this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
   this._itemsBag.callbackObj = this;

   this.clear();
}

Next, I have defined two methods which the directive can invoke using the callback reference to this controller. One is to display the registration of course, and the other is to delete the registration from the list. As you have seen, the directive's controller does not invoke these two methods using the callback object. The HTML code for the directive does invoke them. Here are these two methods:

JavaScript
...
   showRegistrationInfo(item) {
      console.log("callback from the directive - show item info.");
      if (item) {
         this._studentName = item.studentName;
         this._studentAge = item.studentAge;
         this._courseName = item.courseName;         
      }
   }
   
   deleteRegistrationInfo(item) {
      console.log("callback from the directive - delete item from list.");
      if (item) {
         if (this._itemsBag && this._itemsBag.allItems && 
             this._itemsBag.allItems.length > 0) {
            let newRegList = [];
            angular.forEach(this._itemsBag.allItems, function (itemToCheck) {
               if (itemToCheck &&
                  (itemToCheck.studentName !== item.studentName ||
                   itemToCheck.studentAge !== item.studentAge ||
                   itemToCheck.courseName !== item.courseName)) {
                  newRegList.push(itemToCheck);
               }
            });
            
            if (newRegList && newRegList.length > 0) {
               this._itemsBag.allItems = newRegList;
               this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
               this._itemsBag.callbackObj = this;
            } else {
               this._itemsBag.allItems = [];
               this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
               this._itemsBag.callbackObj = this;
            }
         }
      }
   }
...

When the item is sent back from the directive for display, all the method showRegistrationInfo() does is pass the values of the object properties to the input fields. The delete method basically copies the old list as a new list by excluding the item from the old list. Then assigns the new list back to the wrapper object. It also updates the last updated date time and call back object so that the directive can be notified. Again, the implicit communication from parent controller to the directive.

At last, I demonstrate how to pass this controller's method to the directive so that the directive can invoke the method directly, and pass in the parameter. I defined this method:

JavaScript
receivedMessageShow(msgData) {
   if (msgData) {java -jar target/
      alert("Title: " + msgData.title + "; Message: " + msgData.message);
   }
}

How to Test the Sample Application

After you download the sample application as a zip file, please first rename all the *.sj file as *.js file. In the base directory, where you can find the pom.xml, run the following command:

mvn clean install

After the build is successful, run the following command to start up the web server:

java -jar target/hanbo-angular-directive2-1.0.1.jar

When the application starts up successfully, point the browser to the following URL:

http://localhost:8080/

The web page will show the application as the following:

Image 2

As a user, you can enter the course registration. Then click the Add button to add the course registration to the list. You will see the registration display in a table. This shows the parent controller is communicating properly with the directive.

Image 3

Use the buttons found in the table row, you can show the course registration, or delete the course registration from the list.

Image 4

Finally on top of the table, there is the button which demonstrates the method from parent controller being passed into the directive so that directive can invoke it directly. Click it, and you will see an alert box pops up and display a static message. The message is prepared at the directive's controller and the display of popup is done by the parent directive.

Image 5

If you use the browser debugger to go through the code, you can see these controllers' communications happening. They will give you some idea how these implicit and explicit communication works, between parent and children components.

Summary

This is it. Another tutorial for this year's submissions. In this one, I have discussed the ways of communication between an AngularJS directive and its parent controller. In this tutorial, I have discussed three different ways:

  • The first way is to have the parent controller pass in a wrapper object as data for the directive, and directive can setup watchers to be notified when the data properties of this wrapper object has been change.
  • From the directive, it is possible to communicate back with callback object which is the reference to the parent controller. Then this call back reference can be used to invoke the parent controller's methods.
  • It is also possible to pass in parent controller methods, the methods then can be invoked. In this tutorial, the example I have as an exercise also allows data to be passed into the method as parameter.

All three approaches can create some type of tight coupling between the directive and its parent controller. There are ways to avoid this. In my sample application, I have deliberately not called the parent controller's methods via the callback reference from the directive's controller. Instead, I allowed the markup (HTML markup) for the directive to do so, Hence, the directive controller will have no dependency to the parent controller. The HTML markup does have it. And since mark up can change frequently, I just need to know the contract between the markup for the directive and the parent controller, like the in markup, I need to invoke these methods of the callback reference and the parent controller should provide the methods to be invoked. In the end, there is no way of getting rid of dependencies between components. You have to figure out a way to make it loose enough so that one component's change will not affect the other too much. It is a delicate balance that requires calibration by the programmer. I hope you will find this tutorial useful. Good luck!

History

  • 20th October, 2021 - Initial draft

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
Team Leader The Judge Group
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --