Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Sandy Clark
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Sandy Clark ( @sandraclarktw )

Protecting Context-Dependent Promise Callbacks In AngularJS

By on

A while back, when I was revisiting my approach to routing, nested views, and caching in AngularJS, I talked about "locking" a callback to the current state of a component. This locking would prevent the callback from being invoked if the state of the callback had changed prior to callback invocation. This is an idea that I've continued to noodle on. And, as such, I wanted to break it out into its own demo that can be used independently of my previous post.

Run this demo in my JavaScript Demos project on GitHub.

The problem scenario is fairly common, but often overlooked. I have some data request that I need to make. I initiate that request. But, before the request resolves (or rejects), the state of the calling context changes in such a way that the data request and subsequent response are no longer relevant.

At best, the irrelevant callback is invoked silently and no one has to be the wiser. At worst, the irrelevant callback is invoked and has a detrimental affect on the user experience (UX) which leads to confusion, frustration, and support tickets that don't seem reproducible.

To solve this problem, I have started wrapping my callbacks in a "proxy" function that locks in the relevant state of the current component. When the proxy is later invoked, it compares the current state of the component to the "locked in" state; and, if the state has changed sufficiently, it silently returns-out. If the state is still in a consistent state, however, the proxy turns around and invokes the callback with the data response.

You can think of this like a circuit breaker for the state of the component. When the state of the component is consistent, the circuit breaker is closed. When the state of the component is inconsistent, the circuit breaker is open and the callbacks cannot be invoked.

To see this in action, I've set up a small demo in which a selected friend's detail can be displayed. All data requests have a simulated network latency which allows the user to click on multiple links before any particular one can complete. If the detail state changes before a callback is invoked, the callback will be ignored by the proxy.

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

	<title>
		Protecting Context-Dependent Promise Callbacks In AngularJS
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController">

	<h1>
		Protecting Context-Dependent Promise Callbacks In AngularJS
	</h1>

	<!-- I show the list of friends. -->
	<ul>
		<li ng-repeat="friend in friends track by friend.id">

			<a ng-click="showFriend( friend )">{{ friend.name }}</a>

		</li>
		<li ng-if="selectedID">
			<a ng-click="hideFriend()">Close detail</a>
		</li>
	</ul>

	<!-- I show the friend detail (based on the selectedID). -->
	<div
		ng-if="selectedID"
		ng-controller="DetailController"
		ng-hide="isLoading">

		<hr />

		<h2>
			{{ friend.name }}
		</h2>

		<p>
			Description goes here ....
		</p>

	</div>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.3.min.js"></script>
	<script type="text/javascript">

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


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


		// I control the root of the application.
		app.controller(
			"AppController",
			function AppController( $scope, friendService ) {

				$scope.friends = [];

				// I define which friend has been selected for detail-viewing.
				$scope.selectedID = 0;

				loadRemoteData();


				// ---
				// PUBLIC METHODS.
				// ---


				// I close the currently-open friend detail.
				$scope.hideFriend = function() {

					$scope.selectedID = 0;

				};


				// I show the detail for the given friend.
				$scope.showFriend = function( friend ) {

					$scope.selectedID = friend.id;

				};


				// ---
				// PRIVATE METHODS.
				// ---


				// I apply the given data to the local view-model.
				function applyRemoteData( friends ) {

					$scope.friends = friends;

					$scope.friends.push({
						id: -1,
						name: "Friend that will error"
					});

				}


				// I load the remote data.
				function loadRemoteData() {

					friendService
						.getFriends()
						.then( applyRemoteData )
					;

				}

			}
		);


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


		// I control the friend detail.
		// --
		// NOTE: For this demo, I am not using an isolate scope - I am depending on an
		// inherited "selectedID" value to determine which friend to select.
		app.controller(
			"DetailController",
			function DetailController( $scope, friendService ) {

				// I determine if remote data is currently being loaded.
				$scope.isLoading = false;

				// I am the selected friend.
				$scope.friend = null;

				// Since the controller will not be destroyed if the friend ID changes,
				// we have to listen for changes so that we can reload the new data and
				// re-render the view.
				$scope.$watch( "selectedID", handleIdChange );

				// We want to listen for the destroy event so we can clean up the scope
				// and flag it as deleted (this comes into play with callbacks below).
				$scope.$on( "$destroy", handleDestroy );

				loadRemoteData();


				// ---
				// PRIVATE METHODS.
				// ---


				// I apply the remote data to the local view-model.
				function applyRemoteData( friend ) {

					$scope.friend = friend;

				}


				// I handle the destroy event on the scope.
				function handleDestroy() {

					$scope.isDestroyed = true;

				}


				// I handle the change in selectedID, updating the controller, as
				// necessary, for the new data.
				function handleIdChange( newValue, oldValue ) {

					if ( newValue === oldValue ) {

						return;

					}

					loadRemoteData();

				}


				// I load the remote data.
				function loadRemoteData() {

					$scope.isLoading = true;

					// NOTE: We are wrapping our resolve and reject callbacks with
					// protect(). This will protect the callback against state changes
					// that occur during the data request.
					friendService
						.getFriendByID( $scope.selectedID )
						.then(
							protect( handleResolve ),
							protect( handleReject )
						)
						.finally( handleFinally )
					;


					// I handle the resolve event on the promise.
					function handleResolve( friend ) {

						applyRemoteData( friend );

					}

					// I handle the reject event on the promise.
					function handleReject() {

						alert( "Oops, data could not be loaded." );

					}

					// I handle the completion of the promise chain, for cleanup.
					function handleFinally() {

						$scope.isLoading = false;

					}

					// I protect the given callback against state changes that occur
					// between the time of definition and invocation.
					function protect( callback ) {

						// Here, we can lock in the state references that we want to
						// ensure are consistent at the time the callback is invoked. If
						// and of these values change, we can consider the result no
						// longer relevant.
						var selectedID = $scope.selectedID;

						return( proxy );


						function proxy( response ) {

							// Now that the callback is being invoked, let's check to
							// make sure that it is still consistent with the state of
							// the component. If the component was destroyed, of if the
							// selected ID changed, then the callback no longer makes
							// sense to call; and, in fact, it can have a detrimental
							// effect on the user experience if we let it through.
							if ( $scope.isDestroyed || ( selectedID !== $scope.selectedID ) ) {

								console.warn( "Skipping callback due to state change." );
								return;

							}

							// If we made it this far, the state is still consistent -
							// invoke the original callback.
							return( callback.call( null, response ) );

						}

					}

				}

			}
		);


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


		// I provide access to the friend data over simulated network latency.
		app.factory(
			"friendService",
			function friendServiceFactory( $q, $timeout ) {

				var friends = [
					{
						id: 1,
						name: "Sarah"
					},
					{
						id: 2,
						name: "Kim"
					},
					{
						id: 3,
						name: "Joanna"
					}
				];

				// Return the public API.
				return({
					getFriends: getFriends,
					getFriendByID: getFriendByID
				});


				// ---
				// PUBLIC METHODS.
				// ---


				// I get all of the friends. Returns a promise.
				function getFriends() {

					var deferred = $q.defer();

					$timeout(
						function networkLatency() {

							deferred.resolve( angular.copy( friends ) );

						},
						1000,
						false // $q will handle the digest.
					);

					return( deferred.promise );

				}


				// I get the friend with the given ID. Returns a promise.
				function getFriendByID( id ) {

					var deferred = $q.defer();

					$timeout(
						function networkLatency() {

							var friend = friends[ id - 1 ];

							friend
								? deferred.resolve( angular.copy( friend ) )
								: deferred.reject()
							;

						},
						1000,
						false // $q will handle the digest.
					);

					return( deferred.promise );

				}

			}
		);

	</script>

</body>
</html>

As you can see, inside of the loadRemoteData() call, I am passing my callbacks through the protect() function which, in turn, wraps them in a proxy function before they are provided to the promise. Then, if I click on a number of the links in fast succession, you will see that the proxy function ends up ignoring all but the last one:

Protect callbacks in AngularJS.

It's not a pretty solution; but, I think it's an elegant solution. And, it is, without a doubt, a problem that does need to be addressed. Just as with canceling your $timeout() callbacks in the $destroy event, you need to ensure that asynchronous requests don't create an unpleasant user experience when the calling context changes prior to resolution.

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

Reader Comments

4 Comments

I agree that is valid concern. Recently I run into something like this with a custom component that was linked after its parent could be already gone. As there was an HTTP request involved, is more or less the same thing.

On the other hand a more general pattern is needed. What if I have a more complex state than a single value? This is easy I think, perhaps incrementing an ID and attaching to the callback then.

But what if I have more than one than a single origin...like another menu that handles different data. Am I clear?

15,674 Comments

@André,

I agree that a more general pattern would be good. If you have more than one variable you need to track, you just add it to the lock-in portion of the proxy. For example:

function protect( callback ) {
. . . . var thisOne = $scope.thisOne;
. . . . var thatOne = $scope.thatOne;
. . . . var anotherOne = $scope.anotherOne;
}

... but, it definitely gets tedious.

That said, for the most part, just tracking one or two variables has been sufficient for my use cases. And, in the edge-cases that are significantly more complicated (like a series of AJAX methods that get run in serial)... I sort of just "hope" it doesn't happen :(

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