Dustin Horne

Developing for fun...

Components With Shared Data in AngularJS

In this post I'm going to show you how to build and use re-usable components with shared data in Angular 1.x.  While there are similarities to the new component system build into Angular 2.0 Angular 1.5, my component is a completely separate entity and, in my opinion, more robust in most ways. 

On my current project, my team had a need to use a component architecture within our AngularJS Application.  We needed to be able to dynamically inject views into our UI and optionally share data between the container and those views.  We initially set out accomplishing this using Directives, but some of them became fairly complex and had the need to contain reusable components themselves.  The directive approach also did not allow us to develop these components in a consistent fashion with the rest of our application.  It is a very large application consisting of dashboards and "widgets", but various pieces of data are loaded by the dashboard and we wanted to reuse that data within our widgets without making additional AJAX calls to fetch it.  Those widgets, however, could be loaded in various dashboards and we didn't want the widgets to have any real knowledge of where they were contained.  The only expectation would be that they be initialized with the proper data.

Angular 1.5 has introduced a new component model, but it didn't quite fit all of the scenarios we wanted and we didn't want to use services to share the data as users would navigate away from those dashboards and we didn't want the data hanging around.  Given the size of the project and the intermingling of my team and an offshore team, it would be very easy to not realize something was not being cleaned up properly.

After a lot of research and realizing there was no good third party solution, I set off to build my own with the following requirements:

  • Components may or may not depend on data, so they should be able to optionally receive data via ng-model.
  • While a component would be a standard controller / view, if it required data it would know that it is always a component and data would have to be injected in a consistent way.
  • The component view would be loaded using the $templateRequest service to ensure it was fetched from the template cache if it existed there and changing the template url of the component would result in a new view being loaded in its place.
  • Scope of the component would be a child of the parent, but optionally could be created as an isolated scope off of $rootScope.
  • The controller and "controller as" would be resolved from the view but can also be supplied or overridden by the component.

The last bullet point above was especially important for us.  In many cases we have editors for data that contain the same fields but are populated or behave entirely differently.  This allows us to supply a single view but tell it to use a different controller, or let views define their own controllers and optionally override them.  In order to accomplish all of this, I created a single directive that is capable of dynamically injecting views and dynamically injecting the controllers of those views with a supplied model.  So let's get to the code.

We start by defining our Directive itself.  I've called the directive "component".  It's completely imaginative, I know. 

'use strict';
 
(function(angular){
    angular
        .module('Dustin')
        .directive('component', Component);
 
    Component.$inject = ['$rootScope', '$compile', '$templateRequest', '$controller', '$timeout'];
 
    function Component($rootScope, $compile, $templateRequest, $controller, $timeout) {
        return {
            restrict: 'E',
            link: Linker,
            scope: {
                model: '=?ngModel',
                templateUrl: '=',
                isolateScope: '=?',
                controller: '=?',
                controllerAs: '=?'
            }
        };
        //directive body
    }
}(angular));

 

Here we've declared our directive, injected a few things we will be using, defined the directive as an element level directive, Pointed at a Linker function which we will explore next, and defined a few values that may or may not be supplied to the directive itself.  You could modify this code to work as you see fit, but we've defined it as an element so we can have a container defined on the page.  In this case it will be <component />.  Inside of this component element is where we will inject our view.  This allows us to also swap out the view if need be by retaining a container on the page.

We've defined values that we will take as the scope for the directive.  templateUrl is required.  This points us to where to load the view.  We don't have to supply a model if we just want to load an arbitrary view and let it be responsible for getting its own data.  By default the directive's parent scope will be the parent scope of the component.  We can set isolateScope to true, allowing us to derive it from $rootScope instead.  And finally, we can optionally override controller and controllerAs.

Now we'll take a loot at the other two (and surprisingly simple) pieces of the directive, with a little bit of explanation, and then I'll show you the full code.  We'll start by looking at the Linker function.  This function is very simple.  It makes sure that the component element is empty and then adds a watcher to the templateUrl property of the directive scope.  This allows us to change the templateUrl and trigger a new load of the component, completely and dynamically replacing it with a completely different control.  Then it makes a call to Initialize.  Here is the Linker function.

        function Linker(scope, element, attrs) {
			element.empty();
            scope.$watch('templateUrl', function (oldVal, newVal) {
                if (oldVal != newVal)
                    Initialize(scope, element, attrs);
            });

            Initialize(scope, element, attrs);            
        }

 

With our watcher in place and our Linker function all ready to go, the only thing left to do is wire up Initialization.  The Initiailzation function is heavily commented so I'll start by showing you the code and then I'll explain some of the things that I did.  Inside of the Initialize function are also a destroy handler and a cleanup method which are used to make sure references get cleaned up properly.  There is also what appears to be an odd use of a $timeout, which I will explain as well.

        function Initialize(scope, element, attrs) {
            var isolateScope = (scope.isolateScope === true || scope.isolatedScope === 'true');
            var controllerName = scope.controller;
            var controllerAs = scope.controllerAs;

            //Fetch the supplied template
            $templateRequest(scope.templateUrl).then(function (template) {
                if (template != null) {
                    var templateElement = angular.element(template);

                    //if controllerName isn't provided, try to resolve from the template
                    if (controllerName == null || !angular.isString(controllerName))
                    {  
                        var elementController = templateElement.attr('ng-controller');

                        if (elementController == null || !angular.isString(elementController))
                            throw 'When using a Component directive, a controller name must either be supplied as a "controller=" attribute or be specified using ng-controller in the root element of the view. -- ' + scope.templateUrl;

                        controllerName = elementController.trim();
                    }

                    //Create a new scope based on the directive's parent (if one exists) or rootScope and honor the supplied isolation flag
                    var componentScope = (scope.$parent || $rootScope).$new(isolateScope);

                    var componentInstance, componentLocals = {};

                    //Set the $scope property to the scope we created
                    componentLocals.$scope = componentScope;

                    //Populate the locals with the componentModel key so it can be injected
                    componentLocals['componentModel'] = scope.model;

                    //Add the componentModel property to scope so it can be accessed from $scope without being injected
                    componentScope['componentModel'] = scope.model;

                    //Instantiate the controller
                    componentInstance = $controller(controllerName, componentLocals);

                    //Add the componentModel property to the controller instance so it can be accessed in cases of "controller as" without being injected
                    componentInstance['componentModel'] = scope.model;

                    if (controllerAs != null)
                        componentScope[controllerAs] = componentInstance;

                    //Remove the ng-controller attribute from the root element to prevent $compile from trying to instantiate it
                    templateElement.removeAttr('ng-controller');

                    $timeout(function () {
                        //Empty the directive element and append the template
                        element.empty().append(templateElement);
                        //Compile the template with the newly created scope
                        $compile(templateElement)(componentScope);
                    }, 0);

                    componentScope.$on('$destroy', Cleanup);
                    scope.$on('$destroy', DestroyComponent);

                    function DestroyComponent() {
                        componentScope.$destroy();
                    }

                    function Cleanup() {
                        componentScope['componentModel'] = null;
                        componentLocals.$scope = null;
                        scope.model = null;

                        delete componentLocals['componentModel'];
                        delete componentScope['componentModel'];
                        delete scope.model;

                        componentInstance = null;

                        templateElement.remove();
                        element.empty();
                    }
                }
            });
        }

Alright, so what's going on here?  Well, we start by making a template request to get the desired template.  This could be a path to an html file or an angular template path as it uses the built in $templateRequest service and will check the template cache before making an http request.  Once the template is loaded, we create an angular element from the template html.  We will need this to do some processing.

Next we ensure that the view contains an ng-controller if we didn't specify one on our directive and we determine which to use.  Once we have our controller, we create a new scope that the controller will use.  We use the directive scopes $parent (or $rootScope if $parent doesn't exist) as the base to avoid having the directive's scope itself be the parent scope for the component and execute a $new with the desired scope isolation.

The next part is interesting.  We will be using the $controller service to create a new instance of the controller we will be using.  The $controller call takes in the name of the controller and a "locals" object.  This object allows us to custom define how some things are injected.  In this case we are definining the "componentModel" property and setting it to scope.model (which might be null and is populated optionally by ng-model on the component).  What this means is that when we define our controller, we can use 'componentModel' as an injectable item and the model that we've supplied to the component will be injected.  I'll provide a usage example after the full code below to better illustrate this.

We've also defined componentModel in several places.  In addition to adding it to the locals, we also added it to componentScope.  This way if the component being injected happens to inject $scope, it will now have a property called componentModel so it can be accessed via $scope.componentModel.  And finally, we add it as a property to the componentInstance.  What this means is that, when using the "controller as" syntax such as "controllerName as ctrl", a property will be added to the controller itself called componentModel.  This makes it available without injecting "componentModel", however it has limited utility as this does not get set until after the call to $controller so when the controller itself is created and first initialized that property won't exist yet.

And finally we have a controller and we're ready for compilation.  First we make sure we remove the "ng-controller" attribute from the template element of the component.  This is important, otherwise when the $compile function is called, the controller will be constructed again and we don't want this because we've constructed it manually.  Then we use a timeout to empty the directive element, append the template element, and then compile it with the newly created scope.  Since we've set everything up above, the controller instance we've created will be used for the view (and for the controllerAs if it exists).

We round it out by wiring up a couple of cleanup routines.  If the component directive is destroyed it makes sure that $destroy is called on the controller scope for the dynamically injected control.  When the control itself is destroyed, we make sure we release all of our references to any shared data and we tidy up after ourselves.

So why the $timeout around the view compilation?  Well, I'm not sure if this was due to an angular bug or just the way it inherently works, but the issue we ran into was that the components would sometimes not render.  It seemed as though once we were nested inside of other directives, angular would not render the entire UI.  As an example, we had an edit form that we used as a component.  Inside of that edit form we had a pager that was also a component.  And this was inside of a UI Bootstrap accordion.  When we loaded the view that contained the accordion (just a regular Angular view, not a component), it would not render the editor form.  Upon inspection and setting breakpoints, we saw that the controller was in fact being constructed, the element was being added, and compile was being called, but Angular was somehow then removing the content from the page.

Moving the component out of the accordion made the component itself start displaying correctly, however the pager component inside of it would not display.  By wrapping the view compilation in a $timeout we moved it to the end of the execution stack and everything now always renders properly.

Here is the full code for the directive, after which I'll show you a brief usage example:

'use strict';

(function(angular){
    angular
        .module('Dustin')
        .directive('component', Component);

    Component.$inject = ['$rootScope', '$compile', '$templateRequest', '$controller', '$timeout'];

    function Component($rootScope, $compile, $templateRequest, $controller, $timeout) {
        return {
            restrict: 'E',
            link: Linker,
            scope: {
                model: '=?ngModel',
                templateUrl: '=',
                isolateScope: '=?',
                controller: '=?',
                controllerAs: '=?'
            }
        };

        function Linker(scope, element, attrs) {
			element.empty();
            scope.$watch('templateUrl', function (oldVal, newVal) {
                if (oldVal != newVal)
                    Initialize(scope, element, attrs);
            });

            Initialize(scope, element, attrs);            
        }

        function Initialize(scope, element, attrs) {
            var isolateScope = (scope.isolateScope === true || scope.isolatedScope === 'true');
            var controllerName = scope.controller;
            var controllerAs = scope.controllerAs;

            //Fetch the supplied template
            $templateRequest(scope.templateUrl).then(function (template) {
                if (template != null) {
                    var templateElement = angular.element(template);

                    //if controllerName isn't provided, try to resolve from the template
                    if (controllerName == null || !angular.isString(controllerName))
                    {  
                        var elementController = templateElement.attr('ng-controller');

                        if (elementController == null || !angular.isString(elementController))
                            throw 'When using a Component directive, a controller name must either be supplied as a "controller=" attribute or be specified using ng-controller in the root element of the view. -- ' + scope.templateUrl;

                        controllerName = elementController.trim();
                    }

                    //Create a new scope based on the directive's parent (if one exists) or rootScope and honor the supplied isolation flag
                    var componentScope = (scope.$parent || $rootScope).$new(isolateScope);

                    var componentInstance, componentLocals = {};

                    //Set the $scope property to the scope we created
                    componentLocals.$scope = componentScope;

                    //Populate the locals with the componentModel key so it can be injected
                    componentLocals['componentModel'] = scope.model;

                    //Add the componentModel property to scope so it can be accessed from $scope without being injected
                    componentScope['componentModel'] = scope.model;

                    //Instantiate the controller
                    componentInstance = $controller(controllerName, componentLocals);

                    //Add the componentModel property to the controller instance so it can be accessed in cases of "controller as" without being injected
                    componentInstance['componentModel'] = scope.model;

                    if (controllerAs != null)
                        componentScope[controllerAs] = componentInstance;

                    //Remove the ng-controller attribute from the root element to prevent $compile from trying to instantiate it
                    templateElement.removeAttr('ng-controller');

                    $timeout(function () {
                        //Empty the directive element and append the template
                        element.empty().append(templateElement);
                        //Compile the template with the newly created scope
                        $compile(templateElement)(componentScope);
                    }, 0);

                    componentScope.$on('$destroy', Cleanup);
                    scope.$on('$destroy', DestroyComponent);

                    function DestroyComponent() {
                        componentScope.$destroy();
                    }

                    function Cleanup() {
                        componentScope['componentModel'] = null;
                        componentLocals.$scope = null;
                        scope.model = null;

                        delete componentLocals['componentModel'];
                        delete componentScope['componentModel'];
                        delete scope.model;

                        componentInstance = null;

                        templateElement.remove();
                        element.empty();
                    }
                }
            });
        }
    }
}(angular));

So how exactly do you use this thing?  Well it's pretty simple.  If you've copy / pasted and not changed the module name, you'll want to inject the 'Dustin' module into your app.js.  Now, let's say we have a controller that has an object called "Foo".  We want to share that object with a component and edit the "Bar" property in a text field.  Here is what the HTML would look like for the component:

<div ng-controller="barEditorController as beCtrl">
	<input type="text" ng-model="beCtrl.Model.Bar" />
</div>

Here is what the controller would look like.  Notice how we've injected 'componentModel'?  This will be a reference to the Foo object from the parent container.

'use strict';
(function(angular){
    angular
        .module('Dustin')
        .controller('barEditorController', BarEditorController);

    BarEditorController.$inject = ['componentModel'];

    function BarEditorController(componentModel) {
		var self = this;
		self.Model = componentModel;
	}
})(angular);

And finally, here is a basic example of what the HTML of the parent container would look like.  Remember that in a more complex scenario you could bind template-url to a dynamic source as well as override "controller" and "controller-as".

<component template-url="~/app/views/barEditor.html" ng-model="parentCtrl.Foo" />

I'd love to hear any feedback you have in the comments below.  Constructive criticism is always welcome.  I do not in any way consider myself to be an absolute expert in AngularJS so if you have something to teach me I'd love to hear it!  Also check back soon for some new angular and javascript content.  In upcoming articles I'll show you some additional useful bits that I've created such as a javascript driven decision tree, a message hub that uses a pub-sub model for messaging between components in angularjs, and a semi-fluent model validator which some very robust and customizable options for dynamically validating data models.