From Angular to Aurelia Part 4: Page Lifecycle

This is the forth part of the From Angular to Aurelia Series. So far we have looked at Services, as well as how to work with Value Converters. This time we're going to look at page lifecycles.

Taking control over your page display

When building a single page application you tend to require more control over the process of how a page is built, when it should be displayed and what kind of prerequisites must be fulfilled before its display. Coming from .NET Desktop development I always liked the idea of having predefined events or hooks available for specific states of the construction process. The JS-story on the other hand looks very different. There is no standard way and each framework follows its own pattern, if at all.

So you might ask why do we need that at all? Lets consider a typical forms example. The following use cases are potentially interesting:

  1. Given a User fills out a form, when he/she tries to navigate away then a reminder should ask the user for whether the entries should be discarded.
  2. Given a User modifies an existing record, when he/she visits the edit form then the data should be prefetched and the form populated.
  3. Given a page needs large amount of background data when it is discarded then memory has to be freed up properly.
  4. Given a User tries to navigate to the form, when he/she is not logged in then the page shall not be assembled and a notification raised instead.

Reading through the use cases we immediately recognize, that a lot of those are somehow connected to navigation instructions. Thus a perfect candidate to handle those events is the frameworks router. Page related stuff on the other hand might be available directly via the controller/viewmodel. Lets see how we would solve that with AngularJS.

Lifecycles the Angular-Way

OK so lets dig into how to achieve those things with Angular. You can find the sources and a preview on Plunkr. First off Angular does not offer a unified way to access page lifecycles, so as developer you have to use a mix of features to achieve what you want. Those involve on one side Router features as the resolve parameter for a route. The purpose of that is to inject dependencies into the requested route-controller. If any of those happen to be promises, the router is going to wait until they resolve or reject and only then continue execution. The other part are default router events when certain actions, like a navigation instruction, happen. Finally also the controllers scope triggers events, e.g. when the current controller is about to be destroyed.

The app to demonstrate the use cases contains the following three pages. As first you see the home route containing simple links to the other two pages.

<h1>Welcome - DEMO PAGE</h1>
<a href="#/form">Go to form</a>
<a href="#/secure">Go to secure page</a>

Home page markup

Our Form page contains a form with two textboxes for the first- and lastname as well as a save button.

<section>
    <h1>Form</h1>

    <form novalidate name="myForm">
        <div class="form-group">
            <label>FirstName</label>
            <input ng-model="ctrl.firstName" type="text"/>
        </div>

        <div class="form-group">
            <label>LastName</label>
            <input ng-model="ctrl.lastName" type="text"/>
        </div>

        <button ng-click="ctrl.save()">Save</button>
    </form>
</section>

<div style="margin-top: 50px;">
    <a href="#/">Go to home</a>
</div>

Form page markup

Last but not least the Secure page concludes the example by just displaying a simple greeting

<h1>Welcome to the secure page</h1>

Secure page markup

Angular: Use case 1

This use case is often seen as a friendly help for the user, which avoids unintended loss of data entry. As soon as a navigation instruction happens from the within the form page, we'd like to intercept the action and let the user confirm further execution.

In order to do so, our form controller needs to listen for a globally broadcasted event called $locationChangeStart, provided by the ngRoute module. This is done by using the scopes $on method, which accepts the name of the event as first and a callback as second parameter. The callback additionally gets the current navigation instruction event. In there we check whether the form has been modified and then ask the user to confirm whether he wants to discard the changes and continue the navigation process, otherwise halt the execution using event.preventDefault.

app.controller('FormController', function($scope) {
    this.firstName = "";
    this.lastName  = "";

    this.save = function() {
        if(!$scope.myForm.$pristine) {
            alert('Data is saved !');
        } else {
            alert('No data modified !');
        }
    };

    $scope.$on('$locationChangeStart', function canDeactivate(event) {
        if(!$scope.myForm.$pristine) {
            var result = confirm('Do you really want to discard your changes?');
            if (!result) {
                event.preventDefault();
            }
        }
    });
});

Form controller listening for the $locationChangeStart event

Angular: Use case 2

Now on to something that is really often necessary. A data edit form. In order to use that, we'd need to prefetch the data and pass it over to the form. What we'd like to do though, is get the data before the page itself is rendered, so that we don't start out with a blank and then suddenly filled form.

Here we can leverage the previously mentioned resolve parameter, to inject async data into our controller. As part of the route configuration, this happens inside a config function where we a specify the behavior of our router. The second route is the one we're looking at. Resolve takes an object, where each key-value pair will be injected later on to the declared FormController. As said if the return value of the resolving method is a promise, the router will wait for it to resolve or reject.

app.config(function config($routeProvider) {
    $routeProvider
        .when('/', {
            templateUrl: 'templates/home.html',
            controller: 'HomeController',
            controllerAs: 'ctrl'
        })
        .when('/form', {
            templateUrl: 'templates/form.html',
            controller: 'FormController',
            controllerAs: 'ctrl',
            resolve: {
                data: function(dataService) {
                    return dataService.getData();
                }
            }
        })
});

The apps route configuration for passing prefetched data to the FormController

The used dataService is a simple factory, which by default auto-resolves with user-object containing the first- and lastname as properties.

app.factory("dataService", function($q){
    return {
        getData: function(){
            return $q.when({ firstName: 'Vildan', lastName: 'Softic'});
        }
    };
});

A simple Data Service as factory

Now all we need to do is to modify the FormController to accept the newly injected data and bind that to our ng-model-observed properties.

app.controller('FormController', function($scope, data) {
    this.firstName = data.firstName;
    this.lastName = data.lastName; 

injecting resolved service and binding its data to observed properties

Angular: Use case 3

Now this might not be the typical user story, discussed with your client, but nevertheless can become equally important when working with large data structures and tons of references which need to be freed up.

The process of Angular is to throw away the complete controller as soon as a new navigation instruction occurs. All your cleanup and persistence task therefore should happen, when a special event is broadcasted. That one is called $destroy. As depicted in the next listing, all you need to do is listen to the event via the controller $scope and perform your cleanup or persistence logic in there.

app.controller('FormController', function($scope, data) {

    ...

    $scope.$on('$destroy', function deactivate() {
        alert('Goodbye data has been cleaned up !');
    });
});

performing cleanup and persistence logic on controller destruction

Angular: Use case 4

OK this last one is a typical scenario used throughout web apps with secure content, only authorized users should have access to. There are multiple ways to achieve such a task, but since we're talking about lifecycle events lets see how to do it this way. We've already looked at the route-resolver in the second use case in order to execute code before a controller loads. Now we will leverage that to register a authentication-helper for our secure page. We setup another route via the route provider along a new resolve property called auth. It gets an authentication-service injected which, simply for the demo, returns an isLoggedIn method, returning a Boolean value. Based on the result we return an either resolved or rejected promise.

$routeProvider
    ...
    .when('/secure', {
        templateUrl: 'templates/secure.html',
        controller: 'SecureController',
        controllerAs: 'ctrl',
        resolve: {
            auth: function(authService, $q) {
                var result = authService.isLoggedIn();

                if(result) {
                    alert('Access granted !');
                    return $q.when(true);
                } else {
                    return $q.reject(false);
                }

            }
        }
    });

setting up an authentication route guard

The mentioned authentication-service can be seen here. As you see for the sake of the demo it will always return false.

app.factory("authService", function(){
    return {
        user: '',
        isLoggedIn: function(){
            return this.user !== '';
        }
    };
});

a sample authentication service

So far we only created the resolver but still need to handle the relocation in case of unauthorized access. Instead of doing that for each controller, we may use the $rootScope to achieve that on a more general basis. For doing so we need to create a module.run block and inject the $rootScope as well as the $location service. That being done we now can listen to the global $routeChangeError event, triggered because of the rejected resolver method. The 4th parameter does contain the rejected message, in our case the Boolean false. If that's the case, we now may notify our users about their trespassing and reroute them to wherever we'd like.

app.run(function( $rootScope, $location ) {
    $rootScope.$on("$routeChangeError", function(event, current, previous, isLoggedIn) {
        if (isLoggedIn === false) {
            alert('Access prohibited !');
            $location.path("/");
        }
    });
});

global redirection in case of unauthorized routes

Lifecycles the Aurelia-Way

Now that we've seen how AngularJS handles those use cases lets move on to Aurelia's way. One important note right at the beginning is that Aurelia as usual tries to unify processes and expose it's API in a meaningful way. So instead of having different endpoints, events and constructs like resolvers, you'll be pleased to see that all of that works as you'd might expect directly from within your page.
As before you can find all of the code and a live preview on Plunkr

Aurelia: Use case 1

The basic idea when performing operations on the pages lifecycle with Aurelia is that you declare all steps by implementing hooks. Those are simply functions, following a certain naming-convention, now when Aurelia's router invokes the ViewModel it will check out whether you've implemented one or more of those hooks and call them at the appropriate time.

Lets see how the first use case is done with Aurelia. We have VM called Form which simply implements the canDeactivate hook. As the name suggests this gets called as soon as you try to navigate away from the current page and a new navigation instruction is created.

export class Form {

    canDeactivate() {
      if(!this.isPristine()) {
          var result = confirm('Do you really want to discard your changes?');
          return result;
      }
    };
}

Aurelia: Use case 2

How about the second use case you ask? Well typically as said we want to load the data before the UI assembles. Thus the perfect hook to be used is named activate, which gets called upon instantiating the VM. We start by first importing and then injecting our DataService and assigning it to a property inside the constructor. Now as mentioned we implement the activate hook. An important thing to note here is that we return the promise returned by getData as this hints Aurelia to wait for the activation phase until the promise resolved.

import {DataService} from './services';
import {inject} from 'aurelia-framework'

@inject(DataService)
export class Form {

    constructor(dataService) {
      this.dataService = dataService;
    }

    activate() {
      return this.dataService.getData().then( (data) => {
        this.firstName = data.firstName;
        this.lastName = data.lastName;
      });
    }

    ...
}

Aurelia: Use case 3

Deactivation logic you ask? Well you guessed it, implement a hook named deactivate.

...

export class Form {

    ...

    deactivate() {
        alert('Goodbye data has been cleaned up !');
    };

}

Nothing more to say about that :)

Aurelia: Use case 4

Now lets conclude the story with our 4th use case. If you think about what we're trying to do, its checking whether its allowed to activate a given VM. So following the naming convention the hook we're searching for is called canActivate.

Lets take a look at the Secure page. In here we import our AuthService and inject it to the VM. Now as mentioned before we implement the canActivate hook, which simply checks like in the Angular example whether the user is logged in and if not shows a notification. In order to redirect the user to a different page all we need to do is import the Redirect navigation command and return that from our hook. The first parameter is the url we'd like to direct the user to.

import {inject} from 'aurelia-framework';
import {Redirect} from 'aurelia-router';
import {AuthService} from './services'

@inject(AuthService)
export class Secure {

  constructor(auth) {
    this.auth = auth;
  }

  canActivate() {
    if (this.auth.isLoggedIn() === false) {
      alert('Access prohibited !');
      return new Redirect('');

    } else 
    return true;
  }
}

Conclusion

After comparing the two examples you can see that both frameworks offer a way to fulfill our use cases. The difference though is that Aurelia focuses on a streamlined process, which not only offers a simple API by implementing hooks but also by leaving nothing but clean and readable code. That results in a much lesser learning curve and in increased maintainability. If you want to learn more about Aurelia's Screen Activation Lifecycle, visit the docs or check by the official gitter room where tons of community members just wait to help you out.

Photo credit: The Forgotten Memories Theatre via photopin (license)