Advanced routing and resolves

Avoiding callback hell in AngularJS controllers

An indispensable part of any web application is the URL router. The standard routing provider in AngularJS is ngRoute. Routing is one of the first things you’ll get familiar with when developing Angular applications and ngRoute will get you quite far but finally leave you wanting more…

The default routing provider has some limitations that make it not very suitable for complex applications. In fact, it’s downright insufficient for anything but the smallest projects. Apparently the AngularJS team acknowledged this and decided to remove ngRoute from the core. Starting with AngularJS v1.2 you’ll have to include ngRoute as a separate module if you want to use it.

TL; DR: Use AngularUI Router and nested resolves to move data-loading logic out of the controller to avoid code duplication and coupling between controllers.

Do more with AngularUI Router

The team behind the AngularUI project realized the default routing provider was lacking and came up with AngularUI Router. This module is a near drop-in replacement for ngRoute but offers a lot more features:

  • Nested states & views (a view within a view)
  • Multiple & named views (a view next to another view, referenceable by name)
  • Nested resolves (a resolve waiting for another resolve)
  • uiSref directive (URL builder for links)
  • onEnter en onExit callbacks

A big drawback of ngRoute is it supports only a single instance of the ngView directive (the DOM element in which the route template will be included). This means your routes must be a flat list; any nesting would have to be implemented manually (using ngInclude with a variable template name for example). View nesting is very tricky to get right, so it’s a good thing the AngularUI Router provides it out of the box. To define a nested hierarchy, you simply give each route (called state in UI Router) a name and use dot notation to define parent-child relations. For example:

// Define a top-level state:
$stateProvider.state('users', {
url: '/users',
templateUrl: 'views/users.html'
});

// Define a child state for 'users':
$stateProvider.state('users.new', {
url: '/new',
templateUrl: 'views/users.new.html'
});

The presence of a name for each route is the most visible difference when compared to ngRoute. Instead of the URL, a state’s “primary key” is it’s name. The URL becomes a property. Here’s a search & replace you can use if you’re migrating from ngRoute to UI Router.

Resolves

A nice feature of ngRoute is the option to pre-load data for a route. This works through the resolve property of the route definition. The resolved property is then injected into the route’s controller. UI Router supports the same feature in a way that works well with its nested states, meaning it supports nested resolves. In my opinion this is UI Router’s best feature. A nested resolve is basically a resolve which has the result of other resolves injected into it. The resolve will delay it’s execution until all injected properties are resolved.

$stateProvider.state('users.profile', {
url: '/:id',
templateUrl: 'views/users.profile.html',
controller: 'UsersController',
resolve: {
user: function($stateParams, UserService) {
return UserService.find($stateParams.id);
},
tasks: function(TaskService, user) {
return user.canHaveTasks() ?
TaskService.find(user.id) : [];
}
}
});

This is an example of how one resolve can depend on another. The user object will be loaded first, followed by a list of tasks, but only if the user can have tasks. The route will load only if all resolves are successful. This means you won’t have to verify that the user property is an actual user object because if the first resolve fails, the second one will never be tried.

Note: If you’re using ngMin, you’ll have to annotate the dependency injection for each resolve, since ngMin doesn’t support ui-router yet. I’ve opened an issue for that.

What’s so great about resolves?

It’s tempting to just load the data you need from within the controller, since you have direct access to $scope. But what happens when you want to refresh the data? In a CRUD scenario it’s very common to load a list of records, then reload it after adding a new record. You’ll end up with something like this:

angular.module('myApp')
.controller('UsersController', function($scope, UserService) {
UserService.all().then(function(users) {
$scope.users = users;
};

$scope.addUser = function(userData) {
UserService.add(userData).then(function() {
UserService.all().then(function(users) {
$scope.users = users;
};
};
};
});

You can see how this causes code duplication and callback hell, especially if multiple requests have to be done to refresh your data. A quick fix would be to introduce a loadUsers function, then call this on initialization and when a new record is added. That would certainly remove a lot of code duplication, but not tackle the real problem. Consider the following scenario:

  • A user has tasks.
  • A project has timeframes.
  • A task belongs to a timeframe.
  • A timeframe is never referenced explicitly. Instead the current timeframe is inferred by some business logic (e.g. current date/time).

Let’s say we want /projects/:id/tasks to list all tasks for the logged in user for the current timeframe for the referenced project. The controller for this route (UsersController) and it’s container (ProjectController) could look like this:

angular.module('myApp')
.controller('ProjectController',
function($scope, $routeParams, ProjectService) {
ProjectService.get($routeParams.id).then(function(project) {
$scope.project = project;
$scope.currentTimeframe = project.getCurrentTimeframe();
};
})

.controller('TasksController', function($scope, TaskService) {
$scope.$watch('currentTimeframe', function(timeframe) {
TaskService.list($scope.currentUser.id, timeframe.id)
.then(function(tasks) {
$scope.tasks = tasks;
});
});
});

Code like this is guaranteed to cause headaches. The $watch on currentTimeframe is necessary because we want the tasks to reload when the current timeframe changes (for whatever reason), but we’re not loading that data in the same controller so we can’t use a regular callback. A huge downside is that any other data we want to load after loading the tasks must be loaded within that $watch function. This will cause scoping issues when you least expect them and make your unit tests a tangled mess. Besides, TasksController shouldn’t be aware of the existence of currentUser and currentTimeframe; it should know only about tasks. What you have here is tightly coupled controllers.

Using nested resolves, you can avoid this nasty use of $watch. The following code results in much neater controllers:

angular.module('myApp')
.config(function($stateProvider) {
$stateProvider.state('project', {
url: '/project/:id',
controller: 'ProjectController',
resolve: {
project: function($stateParams, ProjectService) {
return ProjectService.get($stateParams.id);
},
currentTimeframe: function(project) {
return project.getCurrentTimeframe();
}
}
});

$stateProvider.state('project.tasks', {
url: '/tasks',
controller: 'TasksController',
resolve: {
tasks: function(TaskService, SessionService,
currentTimeframe) {
return TaskService.list(SessionService.currentUserId,
currentTimeframe.id);
}
}
});
})

.controller('ProjectController', function($scope, project) {
$scope.project = project;
})

.controller('TasksController', function($scope, tasks) {
$scope.tasks = tasks;
});

Let’s go back to the reload scenario. How would you reload the resolved data from within the controller? Calling the same service from within the controller to update the $scope property is not the right answer because that would introduce code duplication (the same service call in two places). The solution is provided by UI Router in the form of the $state.reload() method. This feature was added after some discussion, but it’s current implementation unfortunately does not re-initialize the controller, which means the resolves will reload, but your scope properties won’t be updated accordingly. There is a workaround for this which uses a watch on $state.$current.locals.globals, but it would be much nicer without $watch.


Read my previous article:

Show your support

Clapping shows how much you appreciated Gert Hengeveld’s story.