Skip to main content
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Vicky Ryder
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Vicky Ryder

Revisiting Routing, Nested Views, And Caching With ngRoute In AngularJS 1.x

By
Published in Comments (6)

It's been a few years since I wrote the AngularJS routing system for InVision App. And, once it was done, I didn't really think about it again. But, the recent Adventures in AngularJS podcast, on the Angular-UI router, has me thinking about routing once more. I know that the new AngularJS 2.0 (and maybe 1.4?) router is supposed to provide a world of difference; but, before we step into the future, I wanted to take a minute revisit the functionality that we can get with the current AngularJS 1.x router.

Run this demo in my JavaScript Demos project on GitHub.

To be fair, I think the AngularJS 1.x router is actually pretty solid - the major problem with routing, in AngularJS 1.x, is the ngView directive; it's extremely limited. But, we can make up for that using other core directives like ngSwitch. In fact, if you start to think about building views with "component directives," ngSwitch feels a lot more natural than ngView (or what ngView could have been).

When I built the routing system for InVision App, it took me about 3-4 weeks. Most of that time was spent trying to figure out what the heck a routing system was supposed to do and, more importantly, how to get controllers to ignore irrelevant route-change events. Now, with 2+ years of routing under my belt, I wanted to see how easily I could build a routing service that would solve 95% of my routing requirements.

NOTE: When I say "build a route service," I mean build "on top of," and leverage, the core ngRoute module.

The code that I wrote in this blog post took a little over two [aggregate] days, from scratch. Much faster than the 3-4 weeks it took me the first time.

In retrospect, the biggest mistake I made - two years ago - was trying to get every controller to react to the same route-change event. This proved to be very problematic when trying to determine if a route-change event was relevant to a particular controller. In this version of my routing solution, I'm translating each route-change event into one or more action-oriented events.

Each route is associated with some "action" value, which is nothing more than a dot-delimited list that represents the state of the current application. If you think about the application view as number of nested views, you can roughly think about each item in this dot-delimited list as defining which box is being rendered in a given level of nesting.

Example: main.users.detail.contactInformation

In the following version of the code, if the user were to switch to a route that was associated with the above action, I would take the main $routeChangeSuccess event and translate it into a the following series of events which will be fired sequentially in the same $digest:

  1. route:
  2. route:main
  3. route:main.users
  4. route:main.users.detail
  5. route:main.users.detail.contactInformation

Essentially, I walk the length of the action-list, $broadcast()'ing an event for each location from the $rootScope. While this approach incurs a bit more overhead, it greatly simplifies the association of a Controller with a particular portion of the route-action. Furthermore, each event includes the next item in the route-action which can easily be used [by convention] to render the nested view:

Pike routing on top of AngularJS 1.x.

NOTE: If you believe that $broadcast() is too expensive, you are living in the past. Not only does $broadcast() not trigger a $digest, $broadcast() has been well optimized in recent releases of AngularJS.

The beauty of this approach is that if a route changes to an application-state that is no longer relevant to a rendered Controller, that controller will never receive the route-change event. As such, it will be destroyed and no further steps need to be taken.

The other challenge that I've faced in routing was dealing with AJAX responses that were no longer relevant. It's quite easy to find yourself in a situation where an AJAX request is initiated while a view is relevant but, returns after that view is no longer relevant. At best, this can lead to unnecessary processing; but, at worse, this can lead to accidental page redirects and unexpected error messages if the AJAX returns in error.

To deal with this problem, the following code offers an ability to "lock" a callback to a particular scope and route state. For example:

  • lock( $scope, ajaxCallback )
  • lock( $scope, "id", ajaxCallback )

This will return a proxy that will bypass the given callback if the scope has been destroyed or the given route parameter has changed. By using this locking method, I don't have to try to test inside the controller to see if subsequent actions are still relevant. That said, .lock() is a convenience method that is intended to be simple, not robust.

Bringing it all together, I created a simple service called "pike" that exposes only three methods:

  • pike.bind( scope, [eventType,] handler) - binds the given scope to the sub-action events of the given type. This function returns the next item in the action path according to the current route.
  • pike.lock( scope, [key,] callback ) - locks the given callback to the given state and optional routeParam key. If the scope has been destroyed, or the route param has changed, when the proxy is invoked, the callback is bypassed.
  • pike.param( key ) - returns the given key from the routeParams, coerced appropriately.

In the last item there, I mention that the param will be "coerced appropriately." pike will try to coerce route parameters to numbers where possible so that strict-equality can be achieved. This applies to both the .param() method as well as the new/old parameter collections passed-in with the route event.

That said, here's the demo code - though it may be much easier to absorb in the video.

CAUTION: I am not trying to sell you on this code - this was just a thought-experiment to see how simple I could make a "good enough" routing system on top of the core ngRoute module.

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

	<title>
		Revisiting Routing, Nested Views, And Caching With ngRoute In AngularJS 1.x
	</title>

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

	<h1>
		Revisiting Routing, Nested Views, And Caching With ngRoute In AngularJS 1.x
	</h1>

	<div ng-switch="subview">
		<div ng-switch-when="list" bn-friend-list></div>
		<div ng-switch-when="detail" bn-friend-detail></div>
	</div>

	<!--
		These links are here to help run the demo for the video recording - they
		wouldn't be here otherwise.
	-->
	<p class="demo-links">
		These are here to help run the demo:<br />
		<a href="#/friends/1/bio">#/friends/1/bio</a> - Sarah<br />
		<a href="#/friends/1/likes">#/friends/1/likes</a> - Sarah<br />
		<a href="#/friends/2/bio">#/friends/2/bio</a> - Joanna<br />
		<a href="#/friends/2/likes">#/friends/2/likes</a> - Joanna<br />
		<a href="#/friends/3/bio">#/friends/3/bio</a> - Kim<br />
		<a href="#/friends/3/likes">#/friends/3/likes</a> - Kim<br />
	</p>


	<!-- Template for the FriendList module directive. -->
	<script type="text/ng-template" id="friend-list.htm">

		<div ng-switch="isLoading" class="module">

			<h2>
				My Friends
			</h2>

			<p ng-switch-when="true">
				<em class="loading">Loading friends!</em>
			</p>

			<ul ng-switch-when="false">
				<li ng-repeat="friend in friends track by friend.id">
					<a ng-href="#/friends/{{ friend.id }}">{{ friend.name }}</a>
				</li>
			</ul>

		</div>

	</script>


	<!-- Template for the FriendDetail module directive. -->
	<script type="text/ng-template" id="friend-detail.htm">

		<div ng-switch="isLoading" class="module">

			<p ng-switch-when="true">
				<em class="loading">Loading detail</em>
			</p>

			<div ng-switch-when="false">

				<h2>
					{{ friend.name }}
					&mdash;
					<a href="#/friends">Back to list</a>
				</h2>

				<p>
					<strong>Birthday</strong>: {{ friend.birthday }}
				</p>

				<p>
					<a ng-href="#/friends/{{ friend.id }}/bio">Biography</a>
					&nbsp;|&nbsp;
					<a ng-href="#/friends/{{ friend.id }}/likes">Likes</a>
				</p>

				<div ng-switch="subview">
					<div ng-switch-when="bio" bn-friend-bio></div>
					<div ng-switch-when="likes" bn-friend-likes></div>
				</div>

			</div>

		</div>

	</script>


	<!-- Template for the FriendBio module directive. -->
	<script type="text/ng-template" id="friend-bio.htm">

		<div ng-switch="isLoading" class="module">

			<h3>
				Biography
			</h3>

			<p ng-switch-when="true">
				<em class="loading">Loading bio</em>
			</p>

			<p ng-switch-when="false">

				{{ bio }}

			</p>

		</div>

	</script>


	<!-- Template for the FriendLikes module directive. -->
	<script type="text/ng-template" id="friend-likes.htm">

		<div ng-switch="isLoading" class="module">

			<h3>
				Likes
			</h3>

			<p ng-switch-when="true">
				<em class="loading">Loading likes</em>
			</p>

			<ul ng-switch-when="false">
				<li ng-repeat="like in likes">
					{{ like }}
				</li>
			</ul>

		</div>

	</script>


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

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


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


		// Set up the routes for this application.
		app.config(
			function configureRoutes( $routeProvider ) {

				$routeProvider
					.when(
						"/friends",
						{
							action: "list"
						}
					)
					.when(
						"/friends/:id",
						{
							redirectTo: "/friends/:id/bio"
						}
					)
					.when(
						"/friends/:id/bio",
						{
							action: "detail.bio"
						}
					)
					.when(
						"/friends/:id/likes",
						{
							action: "detail.likes"
						}
					)

					// Define a catch-all for friend-oriented routes. Try to redirect
					// the user back to the friend detail.
					.when(
						"/friends/:id/:notFound",
						{
							redirectTo: "/friends/:id"
						}
					)

					// Define a catch-all for the entire application.
					.otherwise({
					redirectTo: "/friends"
					})
				;

			}
		);


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


		// I provide a light-weight route integration on top of the AngularJS 1.x ngRoute
		// module. The system relies on each route being mapped onto an "action" variable
		// which is subsequently translated into events that are broadcast down through
		// the scope tree.
		app.factory(
			"pike",
			function pikeFactory( $route, $routeParams, $rootScope ) {

				var splitter = /\./g;

				var oldRouteParams = {};

				// I am used as the prefix / name-space for the internal event that will
				// be announced on the scope-chain.
				var eventTypePrefix = "route:";

				// I bind to the route-change event so that it can be translated into
				// action-oriented events on the scope-tree.
				$rootScope.$on( "$routeChangeSuccess", handleRounteChangeEvent );

				// Return the public API.
				return({
					bind: bind,
					lock: lock,
					param: param
				});


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


				// I bind a route-change handler to the contextual scope and return the
				// next item in the action path
				function bind( scope, eventType, handler ) {

					// If we only get a change-handler passed-in, assume that the
					// event-type will be the empty string.
					if ( arguments.length === 2 ) {

						handler = arguments[ 1 ];
						eventType = "";

					}

					// Bind the route-change handler on the context scope.
					scope.$on( ( eventTypePrefix + eventType ), handler );

					// If there is no action associated with the current route, there
					// can be no known "next action"; as such, return null.
					if (
						! $route.current ||
						! $route.current.action ||
						( $route.current.action === eventType )
						) {

						return( null );

					}

					// In order for there to be a relevant next action, the provided
					// event type must be a prefix for the current action. If it is not,
					// then return null.
					if ( $route.current.action.indexOf( eventType + "." ) !== 0 ) {

						return( null );

					}

					// Now that we know we'll have a relevant next action, split both
					// the route action and the event type so that we can determine the
					// next action by index.
					var currentParts = $route.current.action.split( splitter );
					var eventParts = eventType.split( splitter );

					return( currentParts[ eventParts.length ] );

				};


				// I return a proxy to the given callback that will only be invoked if
				// the scope still exists and the route has not changed "too much". This
				// is intended to short-circuit AJAX responses that return after the
				// initiating route context is no longer relevant. This must be called
				// with a scope; but, it can also be called with an optional route param
				// to track.
				// --
				// lock( scope, callback )
				// lock( scope, key, callback )
				function lock( scope, key, callback ) {

					// If a key was omitted, shift argument mappings.
					if ( arguments.length === 2 ) {

						callback = arguments [ 1 ];
						key = null;

					}

					var value = ( key ? param( key ) : null );

					return( proxyCallback );


					function proxyCallback() {

						// If the scope has been destroyed, exit out.
						if ( ! scope.$parent && ( scope !== $rootScope ) ) {

							console.warn( "Response ignored due to scope destruction." );
							return( callback = null );

						}

						// If the scope exists, and the tracked key has changed, exit out.
						if ( key && ( value !== param( key ) ) ) {

							console.warn( "Response ignored due to stale state." );
							return( callback = null );

						}

						// Otherwise, invoke callback.
						return( callback.apply( scope, arguments ) );

					}

				}


				// I get, coerced, and return the value from the current $routeParams.
				function param( key ) {

					return( coerceParam( $routeParams[ key ] ) );

				}


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


				// I try to coerce the given route parameter value in a way that is most
				// expected - if a parameter can be converted to a number, we will return
				// it as a number.
				function coerceParam( value ) {

					if ( angular.isUndefined( value ) ) {

						return( null );

					}

					var numericValue = ( value * 1 );

					return( ( value == numericValue ) ? numericValue : value );

				}


				// I try to coerce all of the local keys in the given params object,
				// converting each value to a number it can be. This will make strict-
				// equality much easier to work with as the only "numeric string" in the
				// entire app will come out of the location data. Everything else should
				// be a "known" value.
				function coerceParams( params ) {

					for ( var key in params ) {

						if ( params.hasOwnProperty( key ) ) {

							params[ key ] = coerceParam( params[ key ] );

						}

					}

					return( params );

				}


				// I catch the core route-change event and then translated it into
				// action-oriented events that get broadcast down through the scope tree.
				function handleRounteChangeEvent( event, newRoute ) {

					// If there is no action, it's probably a redirect.
					if ( angular.isUndefined( newRoute.action ) ) {

						return;

					}

					// Gather the coerced parameters for the new route.
					var newRouteParams = coerceParams( angular.copy( $routeParams ) );

					// Each part of the route-action is going to be announced as a
					// separate route event.
					var parts = newRoute.action.split( splitter );

					// Announce the root change event. This is necessary for anyone
					// who is listening for a route-change but does not provide an
					// event-type to bind to.
					$rootScope.$broadcast(
						eventTypePrefix,
						( parts[ 0 ] || null ),
						newRouteParams,
						oldRouteParams
					);

					// Now, walk down the route-action and announce a different event
					// for each part of the path. So, for example, if the action were
					// "foo.bar.baz", we'll announce the following events:
					// --
					// $broadcast( event, "foo", "bar", new, old );
					// $broadcast( event, "foo.bar", "baz", new, old );
					// $broadcast( event, "foo.bar.baz", null, new, old );
					// --
					// If you think that this causes too much processing, you have to
					// get some perspective on the matter; the cost of traversing the
					// scope tree for event triggering is quite inconsequential when
					// you consider how infrequently route changes are going to be
					// triggered.
					for ( var i = 0, length = parts.length ; i < length ; i++ ) {

						$rootScope.$broadcast(
							( eventTypePrefix + parts.slice( 0, i + 1 ).join( "." ) ),
							( parts[ i + 1 ] || null ),
							newRouteParams,
							oldRouteParams
						);

					}

					// Store the current params for the next change event.
					oldRouteParams = newRouteParams;

				}

			}
		);


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


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

				// NOTE: We are not providing a route-action prefix because this is the
				// root of the application - therefore, we want to know about top-level
				// action changes.
				$scope.subview = pike.bind( $scope, handleRouteChange );


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


				// I handle contextual changes in the route.
				function handleRouteChange( event, nextAction, newParams, oldParams ) {

					// Will be one of the following:
					// --
					// [ list ]
					// [ detail ]
					$scope.subview = nextAction;

				}

			}
		);


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


		// I render the FriendList module.
		app.directive(
			"bnFriendList",
			function() {

				// Return the directive configuration.
				return({
					controller: Controller,
					templateUrl: "friend-list.htm"
				});


				// I manage the view-model for the module.
				function Controller( $scope, friendService, pike ) {

					$scope.isLoading = false;

					$scope.friends = [];

					loadRemoteData();


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


					// I load the remote data and merge it into the local view-model. If
					// cache data is available, it will be consumed.
					function loadRemoteData() {

						$scope.isLoading = true;

						// CAUTION: Using pike.lock() to short-circuit callbacks if the
						// route has changed by the time the callbacks have returned.
						friendService.getList()
							.then(
								pike.lock( $scope, handleResolve ), // Live data.
								null,
								pike.lock( $scope, handleResolve ) // Cache data.
							)
						;


						// I apply the remote data to the local scope.
						function handleResolve( friends ) {

							$scope.isLoading = false;

							$scope.friends = friends;

						}

					}

				}

			}
		);


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


		// I render the FriendDetail module.
		app.directive(
			"bnFriendDetail",
			function() {

				// Return the directive configuration.
				return({
					controller: Controller,
					templateUrl: "friend-detail.htm"
				});


				// I manage the view-model for the module.
				function Controller( $scope, friendService, pike ) {

					$scope.friendID = pike.param( "id" );

					$scope.isLoading = false;

					$scope.friend = null;

					// Setup the route-change event binding.
					// --
					// NOTE: When setting up the route-change binding, .bind() will
					// return the next action item based on the current route state.
					// In this case, it will be [ bio ] or [ likes ].
					$scope.subview = pike.bind( $scope, "detail", handleRouteChange );

					loadRemoteData();


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


					// I handle contextual changes in the route.
					function handleRouteChange( event, nextAction, newParams, oldParams ) {

						// Will be one of the following:
						// --
						// detail.[ bio ]
						// detail.[ likes ]
						$scope.subview = nextAction;

						// If the route ID has changed, we'll have to re-initialize the
						// data for the new friend.
						if ( $scope.friendID !== newParams.id ) {

							$scope.friendID = newParams.id;

							loadRemoteData();

						}

					}


					// I load the remote data and merge it into the local view-model. If
					// cache data is available, it will be consumed.
					function loadRemoteData() {

						$scope.isLoading = true;

						// CAUTION: Using pike.lock() to short-circuit callbacks if the
						// route has changed by the time the callbacks have returned.
						friendService
							.getDetail( $scope.friendID )
							.then(
								pike.lock( $scope, "id", handleResolve ), // Live data.
								null,
								pike.lock( $scope, "id", handleResolve ) // Cache data.
							)
						;


						// I apply the remote data to the local scope.
						function handleResolve( friend ) {

							$scope.isLoading = false;

							$scope.friend = friend;

						}

					}

				}

			}
		);


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


		// I render the FriendBio module.
		app.directive(
			"bnFriendBio",
			function() {

				// Return the directive configuration.
				return({
					controller: Controller,
					templateUrl: "friend-bio.htm"
				});


				// I manage the view-model for the module.
				function Controller( $scope, friendService, pike ) {

					$scope.friendID = pike.param( "id" );

					$scope.isLoading = false;

					$scope.bio = null;

					// Setup the route-change event binding.
					pike.bind( $scope, "detail.bio", handleRouteChange );

					loadRemoteData();


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


					// I handle contextual changes in the route.
					function handleRouteChange( event, nextAction, newParams, oldParams ) {

						// If the route ID has changed, we'll have to re-initialize the
						// data for the new friend.
						if ( $scope.friendID !== newParams.id ) {

							$scope.friendID = newParams.id;

							loadRemoteData();

						}

					}


					// I load the remote data and merge it into the local view-model. If
					// cache data is available, it will be consumed.
					function loadRemoteData() {

						$scope.isLoading = true;

						// CAUTION: Using pike.lock() to short-circuit callbacks if the
						// route has changed by the time the callbacks have returned.
						friendService
							.getBio( $scope.friendID )
							.then(
								pike.lock( $scope, "id", handleResolve ), // Live data.
								null,
								pike.lock( $scope, "id", handleResolve ) // Cache data.
							)
						;


						// I apply the remote data to the local scope.
						function handleResolve( bio ) {

							$scope.isLoading = false;

							$scope.bio = bio;

						}

					}

				}

			}
		);


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


		// I render the FriendLikes module.
		app.directive(
			"bnFriendLikes",
			function() {

				// Return the directive configuration.
				return({
					controller: Controller,
					templateUrl: "friend-likes.htm"
				});


				// I manage the view-model for the module.
				function Controller( $scope, friendService, pike ) {

					$scope.friendID = pike.param( "id" );

					$scope.isLoading = false;

					$scope.likes = null;

					// Setup the route-change event binding.
					pike.bind( $scope, "detail.likes", handleRouteChange );

					loadRemoteData();


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


					// I handle contextual changes in the route.
					function handleRouteChange( event, nextAction, newParams, oldParams ) {

						// If the route ID has changed, we'll have to re-initialize the
						// data for the new friend.
						if ( $scope.friendID !== newParams.id ) {

							$scope.friendID = newParams.id;

							loadRemoteData();

						}

					}


					// I load the remote data and merge it into the local view-model. If
					// cache data is available, it will be consumed.
					function loadRemoteData() {

						$scope.isLoading = true;

						// CAUTION: Using pike.lock() to short-circuit callbacks if the
						// route has changed by the time the callbacks have returned.
						friendService
							.getLikes( $scope.friendID )
							.then(
								pike.lock( $scope, "id", handleResolve ), // Live data.
								null,
								pike.lock( $scope, "id", handleResolve ) // Cache data.
							)
						;


						// I apply the remote data to the local scope.
						function handleResolve( likes ) {

							$scope.isLoading = false;

							$scope.likes = likes;

						}

					}

				}

			}
		);


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


		// I provide access to friend-related data for the purposes of the demo. While
		// we use a single HTTP call to get all of the remote data, we're still going to
		// expose different aspects of the data through different methods and cache the
		// sub-data individually so as to make the example a bit more "real world."
		app.service(
			"friendService",
			function( $q, $http, _ ) {

				// I cache each data point by entity ID.
				var cache = {
					bio: {},
					detail: {},
					likes: {},
					list: {}
				};

				// Return the public API.
				return({
					getBio: getBio,
					getDetail: getDetail,
					getLikes: getLikes,
					getList: getList
				});


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


				// I get the bio for the friend with the given ID. If cached data is
				// available, it will be announced through the notify event.
				function getBio( id ) {

					var deferred = $q.defer();

					// If we have cache data, notify the calling context.
					if ( cache.bio[ id ] ) {

						notify( deferred, angular.copy( cache.bio[ id ] ) );

					}

					getData().then(
						function handleResolve( data ) {

							var friend = extractFriend( data, id );

							deferred.resolve(
								angular.copy( cache.bio[ id ] = friend.description )
							);

						},
						deferred.reject
					);

					return( deferred.promise );

				}


				// I get the detail for the friend with the given ID. If cached data is
				// available, it will be announced through the notify event.
				function getDetail( id ) {

					var deferred = $q.defer();

					// If we have cache data, notify the calling context.
					if ( cache.detail[ id ] ) {

						notify( deferred, angular.copy( cache.detail[ id ] ) );

					}

					getData().then(
						function handleResolve( data ) {

							var friend = extractFriend( data, id );

							var data = _.pick( friend, [ "id", "name", "birthday" ] );

							deferred.resolve(
								angular.copy( cache.detail[ id ] = data )
							);

						},
						deferred.reject
					);

					return( deferred.promise );

				}


				// I get the likes for the friend with the given ID. If cached data is
				// available, it will be announced through the notify event.
				function getLikes( id ) {

					var deferred = $q.defer();

					// If we have cache data, notify the calling context.
					if ( cache.likes[ id ] ) {

						notify( deferred, angular.copy( cache.likes[ id ] ) );

					}

					getData().then(
						function handleResolve( data ) {

							var friend = extractFriend( data, id );

							deferred.resolve(
								angular.copy( cache.likes[ id ] = friend.likes )
							);

						},
						deferred.reject
					);

					return( deferred.promise );

				}


				// I get the list of friends. If cached data is available, it will be
				// announced through the notify event.
				function getList() {

					var deferred = $q.defer();

					// If we have cache data, notify the calling context.
					if ( cache.list.data ) {

						notify( deferred, angular.copy( cache.list.data ) );

					}

					getData().then(
						function handleResolve( data ) {

							var list = _.map(
								data,
								function operator( friend ) {

									return( _.pick( friend, [ "id", "name" ] ) );

								}
							);

							deferred.resolve(
								angular.copy( cache.list.data = list )
							);

						},
						deferred.reject
					);

					return( deferred.promise );

				}


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


				// I find the friend with the given ID in the given data. Throws an error
				// if the friend cannot be found.
				function extractFriend( data, id ) {

					var friend = _.find( data, { id: id } );

					if ( ! friend ) {

						throw( new Error( "NotFound" ) );

					}

					return( friend );

				}


				// I get the remote data and unwrap it for processing.
				// --
				// NOTE: For simplicity of the demo, all the data comes back in a single
				// JSON object; but, only parts of it will be plucked out.
				function getData() {

					var promise = $http({
						method: "get",
						url: "./friends.json"
					})
					.then(
						function handleResolve( response ) {

							return( response.data );

						},
						function handleReject( response ) {

							throw( new Error( "NetworkError" ) );

						}
					);

					return( promise );

				}


				// I notify the promise for the given cached data.
				function notify( deferred, value ) {

					// Hack needed to get notify queue to flush.
					// --
					// SEE: http://bjam.in/2800 for more information.
					deferred.promise.then( null, null, angular.noop );

					deferred.notify( value );

				}

			}
		);


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


		// I simulate some network latency so that we can see the UI in a pending state
		// while remote data is being loaded.
		app.config(
			function simulateHttpLatency( $httpProvider ) {

				$httpProvider.interceptors.push( slowDownRequest );


				// I add a delay to post-HTTP part of the promise-chain.
				function slowDownRequest( $q, $timeout ) {

					return({
						request: function( config ) {

							// We only want to apply the latency to "API"-based calls.
							if ( config.url.search( /\.json$/i ) === -1 ) {

								return( config );

							}

							var latency = $q.defer();

							$timeout(
								function() {

									latency.resolve( config );

									config = latency = null;

								},
								1000,
								false // No need trigger a digest.
							);

							return( latency.promise );

						}
					});

				}

			}
		);


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


		// Expose the lodash library as an injectable.
		app.factory(
			"_",
			function( $window ) {

				try {

					return( $window._ );

				} finally {

					delete( $window._ );

				}

			}
		);

	</script>

</body>
</html>

One of the things that makes this demo so interesting, in the context of routing, is the fact that the data-access layer can serve up cached data using the $q .notify() event. Having data instantly available means that you can never rely on network latency to create a situation in which a Controller will always be destroyed and reinstantiated. As such, each controller has to know how to reload / re-initialize the view-model based on changes in the route state.

To me, this approach is a really nice mariage of simplicity and functionality. It's not the most robust routing system; but, there are so few moving parts that I think it's easy to wrap your head around. More than anything, though, this was just a really fun thought-experiment. I look forward to seeing what the AngularJS 2.0 router has to offer.

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

Reader Comments

18 Comments

Hi Ben -

Fascinating ... as always.

Your post is most interesting to me for the way it calls attention to a number of routing issues that people don't discover for themselves until perhaps too late.

Top of the list is what I perceive as a cry for "**life-cycle events**". They are the **killer feature** of the "new router" coming in 1.x and 2.0. They're completely missing in the existing `ngRoute` and the `UIRouter`. Your essay ... intentionally or not ... reveals how badly we miss them.

For example, you draw attention to the potential problem of an async data call completing AFTER the route has changed and AFTER the calling controller has been destroyed. Great catch!

Is that always a problem? It might be or it might not be, depending upon what you're doing. In your case, the way you've implemented your cached-data-access scheme, it could be a big problem. Out-of-sequence callbacks could crush the data you're caching in `$scope`.

You saw this as a routing problem and addressed it in your `ngRoute` overlay.

That is *one* approach. I'll confess immediately that it gives me the willies. For now the router must know intimate details about your data access methods. Now the router - your enhancements to it - must be programmed to "do the right" thing with all server responses.

aside: I have the same objection when I see folks get clever with the router's `resolve` object.

I challenge you (the reader) to think hard about whether those are appropriate router responsibilities. I challenge you to imagine what happens to this logic as the app's data access needs grow and become more complex.

aside: I note also that you're relying heavily on `$scope` for a range of capabilities including data caching ... which may trigger a village revolt complete with torches and pitchforks. The `$scope` is soon to be retired ... and good riddance I say.

The new router takes a different approach, rooted in years of experience that extends back through the Durandal router written by Rob Eisenberg (the original designer of the new Ng router).

The new router "understands" asynchronous life-cycle events revolving around the deactivation of the current controller and the activation of the new controller. It looks for each controller to (optionally) implement four **promise-returning** methods whose names might be "canDeactivate", "deactivate", "canActivate", "activate".

Returning to your use case, the new router would ask the current controller "is it OK to leave?". If so (and the target controller agrees to be activated), the new router calls "deactivate" ... which gives your controller the option to bail out of its *own pending callbacks* and perform all other cleanup it deems appropriate.

aside: often a controller needs to ask a user if it's OK to leave and (if so) save pending data BEFORE navigating to the new view. The new router handles that gracefully; it's ridiculously complicated to handle this scenario with today's routers.

Why might it be better to delegate activate/deactivate to the controller? I think it's better because it's usually the controller that "knows" what it can and should do as it passes through the life cycle.The controller has a far better handle on what must happen at each critical point than any external apparatus such as the router.

Yes you can handle the callback-killing activity generically in your example ... but only because you have a single, rather simple data access activity to manage.

It doesn't stay simple like that for me. In fact (my shameless moment), I'm more likely to turn to something like breeze.js for cached-data-access and wrap that in a "dataservice" component that I inject into my controllers. Then I worry less about pending service requests ... which the dataservice can handle w/o help from any controller.

This way, the controller only has to worry about its own promise callbacks ... which it can disable, if necessary, in its deactivate method.

I don't want to go down the breezejs rabbit hole right now. But I do think it's worth arguing that the *router* shouldn't be in the data management business and it shouldn't have to guess what the controller can or should do before it dies.

Where does that leave us?

I don't think you were actually saying that the code you present here is suitable for everyone. I think you intended to shed light on challenges that arise in typical applications and to explore what we might do *by hand* to meet those challenges.

I conclude ... and this is me talking ... that your post performs the great service of demonstrating how a seemingly promising extension to today's routers leads into the marsh.

I look forward to your exploration of the new router ... perhaps taking your example scenario and seeing what that new router has to offer.

Yours with deep respect and gratitude, Ward

15,848 Comments

@Ward,

First off, thank you so much for your very thoughtful response. Honestly, I didn't even expect anyone to even look at this stuff! So, that's awesome!

And, just a few points of clarification on some of your observations:

1) The $scope isn't actually caching any data - the caching happens entirely inside the data-access layer (friendService). That said, the $scope **does** have to expect cached data (via the .notify() promise handler). To keep things simple, I'm reusing the same handler for both the Resolve and Notify callbacks, such that both cache data and "live" data get merged into the view-model in same way. The beauty of the .notify() approach is that if the controller doesn't want to deal with cache data (or even know about it), it simply omits the notify-binding and just uses the resolve-binding.

2) pike.lock() doesn't have to know anything about the context - it only knows about the given $scope and the given callback (the $scope is necessary to check for scope destruction ie, $scope.$parent==null). As such, it doesn't matter what the callback represents. So, whether you use a single promise, or a $q.all() or whatever, it only cares about, "do I invoke this function or not."

That said, it definitely falls short in some cases. For example, it will **not work** if there is another promise handler in the chain. Since the pike.lock() method returns a naked value, **if** it were followed by another promise handler, it would always trigger the "resolve" handler.

So, it definitely is not a panacea for async data; and, if the logic gets more complicated, it would fall short. But, if your controller is typically making "one set of HTTP requests" at a time, (whether via a single promise or $q.all()), it should be OK in many situation.

Honestly, I think the biggest problem with async data access is that there is no **easy way** to kill an HTTP-based promise. You can hook into the timeout, and shoe-horn an "abort" method into your promise returns:

www.bennadel.com/blog/2616-aborting-ajax-requests-using-http-and-angularjs.htm

... but, aborting an $http() promise triggers a "reject" on the promise; which, is **not really** the intent. Right? The intent of aborting an AJAX request is to say, "I no longer care.... at all." This includes both the resolution and the rejection. So, even if we can abort an AJAX request, you still sort of need to "ignore" the response. This is why I thought the pike.lock() approach made sense.

I must admit that the extend of what I know about the new router is pretty much confined to what you guys talk about on Adventures In Angular (and one other YouTube video). So, I'm fairly ignorant to what it (and AngularJS 2.0 in general) really brings to the table. That said, I am not sure that lifecycle hooks will really help solve async data access patterns. I totally see the way that they will help the "Do you want to save your unsaved form data?" user-prompt problem; but, if my controller is waiting for data to return, I am not sure that this is something that I want to "block" on. Of course, each situation is different and maybe I don't want the user to leave the current view until we know for sure whether a response is successful. I can dig that.

So, of the four hooks you talked about - "canDeactivate", "deactivate", "canActivate", "activate" - I see the value of the "canDeactivate" one. But, the other three don't connect with me. Specifically, I don't see how "deactivate" is different than the "$destroy" event; or, how "activate" is different than Controller instantiation? Furthermore, if it's the controller's job to know about what it can and can't do based on its state, how can you ask a controller - that has **not** yet been instantiated - whether or not it can be activated?

Now, if I can have your shoulder to cry on for a moment, the AngularJS 2.0 stuff is a bit daunting for me :) I use the $scope pretty heavily - more so than I should; and, I'm trying to fix that. But, one thing that I have actually grown to love, and am trying to leverage more, is the ability to use the $scope chain as a pub/sub mechanism. The beauty of this is that the Controllers automatically unbind their event-listeners during the $destroy event. Which is pure awesomeness! No having to worry about calling .off() on some pub/sub "service" (which is the nightmare that I am in right now).

Ok, brain fried by good conversation :) Thank you very much for the thought provocation.

18 Comments

Hi Ben!

Thanks for taking the time to respond to my comment. I've had the following response on my desk for a week or two and forgot I hadn't posted it :(

Hope it's still relevant. We may be just talking to each other ... but we're having a good time doing it. Exchanging views like this helps me discover what I think ... and challenges my assumptions.

aside: your `$q.notify` with noop is pretty cool. That was tricky indeed. Well done!

-- On $scope --

Evidently I misread your code in many important respects and leaped to unwarranted conclusions about your use of `$scope`. You're not doing all the bad things I accused you of. Thanks for setting me straight.

My weak defense is that I am allergic to `$scope`. IMO opinion, `$scope` is an "atrocity" for at least three reasons:

1. `$scope` is global to the app, enabling inadvertent cross-component corruption and value shadowing.

2. `$scope` is a "god class" without a single, clear purpose.

3. `$scope` is both a container of transient values (a data model for binding a controller to a view) and a grab bag of global services.

I'm so allergic, that I'm moving toward eliminating it completely from my controller code.

The first step toward freedom is "controllerAs" style everywhere ... which prepares you for v.2.0 BTW. You won't see me write `$scope.isLoading` or `$scope.friends = []`. So I tend to forget that the scope of a given `$scope` may be the controller, not global (although how can you tell?).

The next step ... which I have not yet taken as I should ... is to delegate `$scope`'s diverse duties to separate services which can be injected where needed. I'm not sure about this but I think you could encapsulate almost everything worth doing with `$scope` in two services.

1. DigestService - $watch, $digest, $apply, etc

2. MessageService - broadcast, emit (avoid it?), on

That's where the global services go. And I believe that's the direction they're headed with 2.0 when they say that "$scope is dead".

Unfortunately, you occasionally need access to the local scope. I suspect your `pike` service is a case in point. I don't think that `bind` needs the local scope. Why do you need `scope.$on`? Is it not sufficient to listen to the injected `$rootScope`? But I haven't come up with a good way to implement `pike.lock` ... because there is no way other way than through the local scope to determine when the controller should die. Angular doesn't tell the controller when it kills its host scope. I, of course, consider that an Angular defect ... a sure sign of both a half-assed router and a half-assed implementation of controllerAs.

You could get around this by listening to route change events and maintaining your own registry. But then you'll be unable to hide from what I regard is the cruel fact: you really want a different router.

If you don't want to face that fact, I guess you'll have to inject `$scope` when you inject `pike` ... just as you do today. At least it will stick out like the sore thumb that it is.

Of course I handle the chores you've assigned to the pike service in a quite different way ... and am calling into question whether a `pike` service is viable in the kinds of business apps that I encounter ... apps with more demanding data requirements.

-- On the dataservice --

I think you've only scratched the surface of what can go wrong with your dataservice. I think you've assumed that all entity key's are integers which is not the case generally. I think you've assumed that your views only need to access to a single entity at a time. You haven't considered update scenarios in which the response from an insert has consequences (such as the permanent id assigned by the database). These are "standard" complications in my world. I often have no say in the matter of how remote data are keyed or accessed. My users review and modify entity graphs of varying depth and complexity and they don't always fetch or save data when it is most convenient for the developer.

My critique is not that your dataservice is under-powered. That's always how these things start and we have to learn from simple examples. My critique ... if I haven't misread the code again ... is that the router is too dependent upon characteristics of the dataservice. This shows up in `pike.lock`'s awareness of the key AND the relationship between the key and the route params. It is not hard to imagine all kinds of async controller activities (including other remote service calls) that need special handling when the controller dies (or is about to die).

-- On Life Cycle events --

You write: "I don't see how "deactivate" is different than the "$destroy" event"

It's different in at least two respects. First, `deactivate` is async. For example, you might want to store unsaved changes in the cloud ... or at least try to do so; you can't do that with a $destroy handler. Second, I don't need to inject $scope so I can listen to $destroy; I can rely on the life-cycle protocol ... as I look forward to 2.0.

You write: "I don't see how ... "activate" is different than Controller instantiation? Furthermore, if it's the controller's job to know about what it can and can't do based on its state, how can you ask a controller - that has **not** yet been instantiated - whether or not it can be activated?"

Ah but you missed that the new router **instantiates the new controller** before calling the `canActivate` and `activate` methods! That's different from existing routers. And that is why **it is critical to do nothing in the constructor other than set variables.**

This is a BIG CHANGE from current common practice ... although we've known in static languages for years that we should do as little as necessary in constructors. Well that same advice applies in JavaScript ... and the Angular team has said so too(citation needed).

It also happens to make testing controllers easier. Right now, they have a bad habit of running away and making service calls during `beforeEach`. You have to rein them in by faking out certain dependencies ... effort that is often a distraction from your immediate test goals. If your controller doesn't do anything until something calls `activate`, you have an opportunity to tweak things after controller construction and before you poke it.

--

It's always great talking to you Ben!

15,848 Comments

@Ward,

The ControllerAs is definitely a really interesting feature. I've only dabbled with it a bit, but have not really used it in production at all. It is something that should get more of my attention. I've definitely committed some sins with the way I used $scope; but, in all fairness, this is the wild-west for me (personally) and it's required a tremendous amount of learning, trial and error, and failure (that, more often than not, lives on in Production until we have time to fix it).

I think that using the ControllerAs syntax would have fix some of those problems; or, at least, made them much more difficult to create in the first place (enough to trigger some mental alert).

I've had to implement some "workflow practices" internally to get around problematic cases. For example, *never mutate* data that you don't "own". So, for example, if you're going to inherit a collection on the $scope, you can read from it, but you **cannot alter it** in any way. Any need to do so must be done through scope-based methods that are provided by the same controller that also owns said collection.

So, for example, if a $scope owns a "friends" collection, it may also expose a $scope.removeFriend( friend ) or a $scope.deactivateFriend( friend ) that could be used by descendant controllers.

I like to think about this in same terms that public / private values work in OOP (not that I am any good at OOP). You can inherit values from a super-class; but, you're not really supposed to act on any inherited private data - only leverage inherited public values and methods. Though, in practice, this is probably not true :)

Of course, all of this is being done because this cross-controller communication is so problematic over the long term.

That said, I do love all the watch and event based features. Moving them to their own service is a very interesting approach. I had never considered that. I would hope that a controller's destruction could still auto-unbind and auto-unwatch stuff, though, as I find that to be a very useful feature of the framework. Even now, in production, I have a separate "modelEvents" service that is just pub/sub and it's a constant source of unexpected behavior as developers forget to unbind event handlers when controllers are destroyed.

I see what you are saying about the Controller lifecycle. I admit that I didn't know about the details of the updated workflows around instantiation and teardown.

I think what you are discovering is that my skillset revolves more around the details and less so around the architecture. That is my Achilles heel. I love the little details - I relish the understanding of scope digests and linking and rendering... but, I find it hard to wrap my head around how it all fits together at the macro level. Even after a few years of this stuff, I find it hard to sit down and think about a how a really complex page fits together in terms of shared data.

I've come to realize that I want stuff as decoupled as possible; but, at the same time, I don't want to go crazy with data loading when transient data can be shared. I know people will try to sell "isolate scope" as the way to solve this problem; but, it doesn't really - it only creates a layer of indirection in naming - it doesn't truly decouple views, just names. Sure, I can pass in a "friends" collection in the isolate scope, instead of inheriting it via $scope.friends. But, that doesn't mean that it's safe to mutate that collection in a nested view - that results in all the same problems I had before. And, sure, you could pass-in all the methods that one could use to mutate the collection... but, the developer needs to know to use those (instead of just mutating the isolate scope values directly). And, when all is said and done, all you really end up with is "ceremony" around problems that you already had to solve. But, maybe that's the point - the ceremony and the explicitness. Not sure - like I said, this is all the stuff that I find it hard to grok.

15,848 Comments

@Ward,

Going back to the pike.lock() method for a moment, I've been thinking about my internal use-case for that which may explain the different point of view. I think about it really only in terms of data **load**, not data **mutation**. You talk about having really complex data access patterns, which I think I do to (in some cases). I have nested promises, workflows that require multiple AJAX calls, etc. And, for those, I definitely wouldn't use pike.lock() because when it comes to changing the state of the system, I don't want to dismiss anything. And, it sounds like you don't either.

I guess I think about this more like, "I need to get the data for this view." Like, I am showing a list of Accounts or Orders, and the think that I'm "locking" is the method that translates the initial data load into the view-model. In those cases, since I am **not changing the state of the system**, I don't really care if I dismiss the result of the AJAX response.

To make it more concrete, imagine that I have a list of Orders and my controller as the following methods:

* loadOrders() -- get data for the view.
* refreshOrders() -- reloads the data for the view (if its stale).
* refundOrder( order ) -- voids / refunds the selected order.

The first two - loadOrders() and refreshOrders() - I would be fine "locking". But, the last one - refundOrder() - changes the state of the system, and I *probably* would not lock it since I really do want to handle the outcome explicitly.

That said, I will often "optimistically delete" records. Meaning, I'll initiate a delete request but then not wait for the result, assuming that a delete will work if the data is in the view to being with. In cases like that, I make "lock" the result since I don't want the user alerts if the delete fails (which is very unlikely).

I hope the more concrete example helps elucidate my point of view. I would tend to lock "data loading" and tend to *not* lock "data mutation."

1 Comments

Hi Ben,
Thank you for your great articles. I have read your articles about angular routing system "Nested Views, Routing, And Deep Linking With AngularJS 2013".
Actually I'm really confused about what should I do. I am going to build a mobile application similar to Google play store layout and it was the reason I get familiar with angularJs, So I am completely new with that. The challenge I have is that do I really need nested views for implementing this layout or I can implement it as simple as possible without nested views? if yes, which solution is the easiest one for a newbie: your solution(this article) or using angular-ui/ui-router?
Sorry for asking unprofessional question but I really need a guidance.

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