Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Jacob Holloway
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Jacob Holloway

Directive Link, $observe, And $watch Functions Execute Inside An AngularJS Context

By
Published in Comments (17)

When you run code inside an AngularJS Controller or Service object, you never have to worry about calling $apply() since your code is executing inside of an AngularJS "context." By this, I mean that AngularJS is aware that your code is running and will perform a dirty-check after the code has completed. When you're inside a Directive, however, AngularJS' view of the world is a bit more limited; it is the job of the Directive to call $apply() (or trigger $apply with something like $timeout) when AngularJS needs to be told about changes to the View Model (ie. $scope). Knowing when to do this, however, is a little tricky because some aspects of the Directive are actually executing inside of an AngularJS context.

If you've started to create your own Directives, there's little doubt that you've seen one of the following two messages:

  • $apply is already in progress.
  • $digest is already in progress.

These error messages indicate that you are trying to tell AngularJS about code that it already knows about. At best, you simply see this error in your console. At worst, this throws you into a recursive fiasco that crashes your browser (one of the very few downsides to using Firebug).

You're probably getting this error because you're inside of a Directive and you read that you have to tell AngularJS about all changes to the $scope that occurred inside of the Directive. And, while this philosophy is true, there are aspets of the Directive that AngularJS is already monitoring. Specifically, AngularJS already knows about:

  • The link functions.
  • The attribute $observe() handlers.
  • The $scope $watch() handlers.

If you make changes to the $scope inside the synchronous execution of the link function, or the asynchronous execution of the $observe() and $watch() handlers, you're already executing inside of an AngularJS context. This means that AngularJS will perform a dirty-check after your code has run. And, AngularJS will trigger additional $digest life-cycles if necessary.

To demonstrate this, I have put together a small directive that watches for the load event on an image. If the image has already loaded by the time the directive executes, the load handler is called immediately - inside the AngularJS context. If the image has not loaded by the time the directive executes, the load handler is invoked asynchronously, and AngularJS needs to be made explicitly aware of the change.

NOTE: I have $observe() and $watch() handlers here simply for demonstration purposes - they don't actually do anything.

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Directive Link, $observe, And $watch Functions Execute Inside An AngularJS Context
	</title>
</head>
<body>

	<h1>
		Directive Link, $observe, And $watch Functions Execute Inside An AngularJS Context
	</h1>

	<ul ng-controller="ListController">

		<!-- These images have dynamic SRC attributes. -->
		<li ng-repeat="image in images">

			<p>
				Loaded: {{ image.complete }}
			</p>

			<img
				ng-src="{{ image.source }}"
				bn-load="imageLoaded( image )"
				width="16"
				height="16"
				style="border: 1px solid #CCCCCC"
				/>

		</li>

		<!-- This image has static SRC attribute. -->
		<li>

			<p>
				Loaded: {{ staticImage.complete }}
			</p>

			<img
				src="4.png"
				bn-load="imageLoaded( staticImage )"
				width="16"
				height="16"
				style="border: 1px solid #CCCCCC"
				/>

		</li>

	</ul>



	<!-- Load jQuery and AngularJS from the CDN. -->
	<script
		type="text/javascript"
		src="//code.jquery.com/jquery-1.9.0.min.js">
	</script>
	<script
		type="text/javascript"
		src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js">
	</script>
	<script type="text/javascript">


		// Create an application module for our demo.
		var Demo = angular.module( "Demo", [] );


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// I am the controller for the list of images.
		Demo.controller(
			"ListController",
			function( $scope ) {


				// I flag the given images as loaded.
				$scope.imageLoaded = function( image ) {

					image.complete = true;

				};


				// Set up the image collection. Since these images are
				// all being loaded at DATA-URIs, they will be loaded
				// immediately.
				$scope.images = [
					{
						complete: false,
						source: "1.png"
					},
					{
						complete: false,
						source: "2.png"
					},
					{
						complete: false,
						source: "3.png"
					}
				];


				// I am the static image example.
				$scope.staticImage = {
					complete: false,
					source: "4.png"
				};


			}
		);


		// -------------------------------------------------- //
		// -------------------------------------------------- //


		// I evaluate the given expression when the current image
		// has loaded.
		Demo.directive(
			"bnLoad",
			function() {

				// I bind the DOM events to the scope.
				function link( $scope, element, attributes ) {

					// I evaluate the expression in the currently
					// executing $digest - as such, there is no need
					// to call $apply().
					function handleLoadSync() {

						logWithPhase( "handleLoad - Sync" );

						$scope.$eval( attributes.bnLoad );

					}


					// I evaluate the expression and trigger a
					// subsequent $digest in order to let AngularJS
					// know that a change has taken place.
					function handleLoadAsync() {

						logWithPhase( "handleLoad - Async" );

						$scope.$apply(
							function() {

								handleLoadSync();

							}
						);

					}


					// I log the given value with the current scope
					// phase.
					function logWithPhase( message ) {

						console.log( message, ":", $scope.$$phase );

					}


					// -------------------------------------- //
					// -------------------------------------- //


					// Check to see if the image has already loaded.
					// If the image was pulled out of the browser
					// cache; or, it was loaded as a Data URI,
					// then there will be no delay before complete.
					if ( element[ 0 ].src && element[ 0 ].complete ) {

						handleLoadSync();

					// The image will be loaded at some point in the
					// future (ie. asynchronous to link function).
					} else {

						element.on( "load.bnLoad", handleLoadAsync );

					}


					// For demonstration purposes, let's also listen
					// for the attribute interpolation to see which
					// phase the scope is in.
					attributes.$observe(
						"src",
						function( srcAttribute ) {

							logWithPhase( "$observe : " + srcAttribute );

						}
					);


					// For demonstration purposes, let's also watch
					// for changes in the image complete value. NOTE:
					// the directive should NOT know about this model
					// value; but, we are examining life cycles here.
					$scope.$watch(
						"( image || staticImage ).complete",
						function( newValue ) {

							logWithPhase( "$watch : " + newValue );

						}
					);


					// -------------------------------------- //
					// -------------------------------------- //


					// When the scope is destroyed, clean up.
					$scope.$on(
						"$destroy",
						function() {

							element.off( "load.bnLoad" );

						}
					);

				}


				// Return the directive configuration.
				return({
					link: link,
					restrict: "A"
				});

			}
		);


	</script>

</body>
</html>

As you can see, I'm loading three images dynamically (inside the ngRepeat) and one image statically. All four images monitor the load event using the bnLoad directive and log aspects of the directive's lifecycle.

The static image - 4.png - will already have been loaded by the time the directive executes. It will be the cause of the first load-handler to be executed in the following console output:

handleLoad - Sync : $apply
$observe : 4.png : $digest
$watch : true : $digest
$observe : 1.png : $digest
$watch : false : $digest
$observe : 2.png : $digest
$watch : false : $digest
$observe : 3.png : $digest
$watch : false : $digest
$observe : 1.png : $digest
$observe : 2.png : $digest
$observe : 3.png : $digest
handleLoad - Async : null
handleLoad - Sync : $apply
$watch : true : $digest
handleLoad - Async : null
handleLoad - Sync : $apply
$watch : true : $digest
handleLoad - Async : null
handleLoad - Sync : $apply
$watch : true : $digest

I know that it's probably difficult to make head or tails of this, so I'll point out a few key items:

The first handleLoadSync() call was triggered by the static image. Notice that it is already inside of the $apply phase of the AngularJS dirty-check lifecycle. This is because it was invoked within the link() function, which is already inside an AngularJS context.

All of the $observe() and $watch() handlers are inside of the $digest phase of the AngularJS dirty-check lifecycle. Once inside this, you will not need to tell AngularJS about any changes made to the $scope. AngularJS will automatically perform a dirty-check after each $digest.

All of the images that loaded asynchronously triggered the handleLoadAsync() method, which invoked the $apply() method in order to tell AngularJS about the change. This is why all of the subsequent handleLoadSync() methods are in the $apply phase - they were invoked from the handleLoadAsync() handler.

As I've said before, AngularJS directives are very powerful; but, they are also multi-faceted beasts and bit a of struggle to wrap you head around. Timing, in an AngularJS directive, is critical to its functionality, and one of the more difficult things to get right. Throw in something like CSS transitions - which only have partial browser support - and you'll quickly find yourself having $digest problems in one browser and not the other. Hopefully this exploration will help make the reason for these problems a bit more clear.

Want to use code from this post? Check out the license.

Reader Comments

1 Comments

I do not understand. The attribute called "bn-load". Is it special attribute? Is "bn-" and "ng-" some kind of special attributes? Yes, I understand that "ng-" means aNGularjs and should be used only by AngularJS Core and Plugins. Did I understand right? Returning to "bn-load", the directive called bnLoad. Hmm, is it because attributes can't have uppercase letters and if I put "-" sign before letter it allowed to name directive with uppercase? Hmm, I think I got ya.
My purpose is just to wait until all images loaded, do I need put $q.deffer on each image and then wait until all have been loaded? Any example please? Can't find information in the Web.

15,848 Comments

@Uelkfr,

Those prefixed attributes are "directives." The "ng-" ones are the ones that come natively with AngularJS; the "bn-" ones are the custom ones that I have created ("bn" stands for Ben Nadel).

When AngularJS takes control of part of a page, it compiles the templates and links the code. Part of this compilation is looking at those custom attributes to see if it needs to run special code that you provide. They are just a way of telling AngularJS, "Hey, run this code against that DOM node."

If you wanted to wait until all images were loaded, you could probably do something like this:

1. Create a root controller that can keep track of how many images there are and how many of them are loaded.

2. Create an "img" directive that can invoke methods on that "root" controller to add images and then tell the controller when those images have loaded.

Then, when the number of "known" images is the same as the number of "loaded" images, you know that all images have loaded in the given controller.

What are you going to do *after* the image have loaded?

15,848 Comments

@Alesei,

Glad you like it. Even after working with AngularJS for months, I still get a bunch of unexpected, "$digest is already in progress". So hard to debug sometimes!

15,848 Comments

@Alesei,

100% agreed! Sometimes, there's nothing more fun that gaining a small bit of insight on some really complicated / unexpected corner of the framework. For example, I'm currently debugging some "force repaints" in my app that are due to the linking phase of some directive. In doing so, I'm trying to learn more about how Chrome Dev Tools works and using their timeline stuff:

http://www.smashingmagazine.com/2013/06/10/pinterest-paint-performance-case-study/

It's super exciting to think I may actually be able to solve my rendering time problems!!

8 Comments

@Ben,

Cool, i am not there yet, will be starting profiling soon though, so might need to reach out for advice : )

I ve built bunch of directives, but most of them were tag replacement directives, widgets. I haven't touched linking phase my self much. Now building attribute directives, so having few things to worry about.

15,848 Comments

@Alesei,

That's funny - I am on the exact other side - my directives are almost exclusively about linking - I've done very little when it comes to tag replacements and widgets :D

1 Comments

Hey Ben,
I am writing a directive to dump a list on a page based on html data I retrieve async from the database server.
see below:
I thought that "MenuService.getMenuData().then" would wait for the data but some how the directive ends before the data arrives. I know I could put a timeout delay but that is not good. Do you have a suggestion as to what could be the problem?

TBApp.directive('tbnavMenu', function ($compile, MenuService) {
var tbTemplate = '<span>3empty</span>';

MenuService.getMenuData().then(function (val) {
tbTemplate = val;
});

var getTemplate = function () {
return tbTemplate;
}

var linker = function (scope, element, attrs) {
...
}

return {
restrict: "E",
rep1ace: true,
link: linker,
controller: function ($scope, $element) {
...
}
}

1 Comments

Hello, I have the same problem as @Luis.

I have my-directive which just loads template like this:

<div class="wrap" my-directive>
<div class="boxes" directive-handling-boxes>
<div class="box" ng-repeat="box in boxes">
....
</div>
</div>
</div>

in directive-handling-boxes is controller which loads some boxes data using factory function with $http(...).then(...) and puts them into $scope

there is also link function with function which should recount size of each box and apply changes to the template, but it runs before data load function and ng-repeat is processed, so i have to use $timeout(coutBoxSizes, 1000) to rerun this function to apply that changes..

It works fine, but i'm wondering if there is some better way to to this without $timeout, do you have any suggestions.

Thx

2 Comments

Am having same issues as Luis. Any insight will be helpful. Basically I am creating a recursive tree view. When I place the data in the scope it works perfectly but when I load data from an api and query it will not load fast enough and the directive will just assume the scope is empty. Anyone have ideas how to overcome this?

1 Comments

Great analysis, thanks for sharing. One thing I would do differently would be to actually create an isolate scope to pass in the expression for imageLoaded()

scope: {
imageLoaded: '&'
}

I typically like to see what my directive is affecting in the outside context. This isn't exactly related to the analysis, but just wanted to share the feedback.

Thanks!

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel