Angular

Angular UI-Router: An Elegant & Maintainable Way to Prevent State Change

Javier Ochoa

There's no doubt: UI-Router is great in routing through states in an Angular app. However, my team recently faced something that required a little bit of investigation, since UI-Router does not provide an easy way for preventing state transition based on application state.

Our requirements were similar to controlling steps in a wizard, where before you can go to a second screen you need to fulfill the requirements of a previous screen. Since we use states, we'll need to prevent steps down the road from working until all the previous ones have been successfully completed. This is particularly important when the user deep links into a state and your models are not ready for that state.

Ready? So, we are using UI-Router 0.2 and Angular 1.4. In this version of UI-router, the best practice for preventing a state change is listening to an event. In the new UI-Router 1.0.0.Alpha there is a functionality called "transition hooks," which control the state transition, but here I'm not just talking about 0.2 and in particular, listening to the $stateChangeStart event on rootScope.

The example I am using is a simple wizard for step-by-step registration. It is really simplified to just capture the First Name, Last Name and D.O.B. of a user. Each question is in its own state, and the requirement is that you can't navigate to the second or third step without completing the previous ones.

Okay, first we need our partial setup with convenience links for the states:

<form>
     <ul>
          <li><a ui-sref="app.one">One</a></li>
          <li><a ui-sref="app.two">Two</a></li>
          <li><a ui-sref="app.three">Three</a></li>
     </ul>
     <div class="container" ui-view ng-controller='controller'></div>
</form>

Note: I am using one "controller" for all states. This is not the recommended approach, but it's done here to simplify the code.

In the JS file, we'll define the module, states, controller and one service to keep the "app" state as follows:

// Reusable button across states
var completeRegistration = '<a ui-sref="app.done"><button type="submit">Next</button></a>';

angular.module('myApp', ['ui.router'])
     .config(['$stateProvider',
          function($stateProvider) {
               $stateProvider
                    .state('app', {
                         abstract: true,
                         template: '<ui-view/>',
                         url: '/'
                    })
                    .state('app.one', {
                         url: '/one',
                         template: '1. First Name: <input type="text" ng-model="user.firstName">' + completeRegistration
                    })
                    .state('app.two', {

                         url: '/two',
                         template: '2. Last Name: <input type="text" ng-model="user.lastName">' + completeRegistration
                    })
                    .state('app.three', {
                       url: '/three',
                       template: '3. DOB: <input type="text" ng-model="user.dob">' + completeRegistration
                    })
                    .state('app.done', {
                    url: '/done',
                    template: 'Thanks for completing your registration !'
                    });
     }
])
.controller('controller', ['$scope', 'userService', function($scope, userService) {
     // Just link the user to scope
     $scope.user = userService.user;
}])
.service('userService', function() {
     // Just declare an object to maintain the state
     this.user = {};
})
.run(['$state', function($state) {
     $state.go('app.one');
}]);

Notice how I'm using an abstract state called "app". This will make more sense later, so for now, just ignore it. If you run this app, you can transition to app.one, app.two and app.three without issues. Our goal is to prevent the transition to those states if one of the previous ones has not been visited. Next, we'll declare in the run phase a simple event listener for the $stateChangeStart event:

$rootScope.$on('$stateChangeStart', function(event, toState, toParams) {

 // called every time the state transition is attempted

}

Now we need a place to store a particular rule to execute and know whether the state can be "transitioned to" and if not then know what state to go to. In UI-Router there is Custom Data that can hold our "rule". This rule however, needs to be dynamicit needs to know the state of the application to correctly transition to a state that should be transitioned to based on the state. By "dynamic", I mean it needs evaluation of services and models.

So what if we could implement the rule the same way we do a resolve, using angular dependency injection and all that fun stuff? We could write a rule that returns the state to "go to instead" or "redirect" with something like this (for state two):

               .state('app.two', {
                    url: '/two',
                    template: '2. Last Name: <input type="text" ng-model="user.lastName">' + completeRegistration,
                    data:{
                         redirect: ['userService',function(userService){
                              // just check that firstName is in, if not return the state where this is filled
                                  if(!userService.firstName) {
                                       return 'app.one';
                                  }
                          }]
                     }
               })

Well, this is possible if we resort to angular $injector. In the $injector, there is a function invoke that does exactly that: resolve dependencies, inject services and execute. So we can start adding this to our $stateChangeStart event listener:

$rootScope.$on('$stateChangeStart', function(event, toState, toParams) {
          // Verify that the state we are moving into has a redirect rule 
          if (toState.data && toState.data.redirect) {
               // If it has then call injector.
               var redirectTo = $injector.invoke(toState.data.redirect);

               // Check that the call returned a state
               if (redirectTo) {
                         // and go to that state instead
                         event.preventDefault();
                         $state.go(goToState);
                    }
               }
          });

Ok, great. Now we can configure our states as follows to make the rules work for us:

               .state('app.two', {
          url: '/two',
          template: '2. Last Name: <input type="text" ng-model="user.lastName">' + completeRegistration,
          data: {
               redirect: ['userService', function(userService) {
                    if (!userService.user.firstName) {
                         return 'app.one';
                    }
               }]
          }
     })
     .state('app.three', {
          url: '/three',
          template: '3. DOB: <input type="text" ng-model="user.dob">' + completeRegistration,
          data: {
               redirect: ['userService', function(userService) {
                    if (!userService.user.lastName) {
                         return 'app.two';
                    }
               }]
          }
     })
     .state('app.done', {
          url: '/done',
          template: 'Thanks for completing your registration !',
          data: {
               redirect: ['userService', function(userService) {
                    if (!userService.user.firstName) {
                         return 'app.one';
                    } else if (!userService.user.lastName) {
                         return 'app.two';
                    } else if (!userService.user.dob) {
                         return 'app.three';
                    }
               }]
          }
     });

Notice how I didn't include app.one. That state does not have any requirement, as it's the starting state.

At this point, we have states that can't be transitioned to until something else happens in other states. However, there are some more improvements that can be done.

Let's go back to the use of the abstract state app. From UI-router Custom Data, we know that the custom data can be inherited and overridden. We could have a rule in the parent state like this:

.state('app', {
     abstract: true,
     template: '<ui-view/>',
     url: '/',
     data: {
          redirect: ['userService', function(userService) {
               if (!userService.user.firstName) {
                    return 'app.one';
               } else if (!userService.user.lastName) {
                    return 'app.two';
               } else if (!userService.user.dob) {
                    return 'app.three';
               }
          }]
     }
})

That will make any of the children states execute this rule, which has different outputs depending on the state. But it creates a small problem with app.one. Since that state is also protected by this rule, we will run in an infinite loop, executing this rule and trying to transition to app.one to execute the rule again. The solution? Simply override the rule for app.one like this:

.state('app.one', {
          url: '/one',
          template: '1. First Name: <input type="text" ng-model="user.firstName">' + completeRegistration,
          data: {
               redirect: undefined
           }
     })

And that's it. Now we have a single definition for all rules in our states, and the states cannot be transitioned to until the service is happy.

Additionally, if you want to get a hold on the state to be transitioned to (or the origin one) and the params, you can easily make those injectable in your rule using the $injector locals:

var redirectTo = $injector.invoke(toState.data.redirect, this, {
          toStateParams: toParams,
          toState: toState
     });

Here we can use toState and toStateParams as injectable pieces in our rule:

data: {
          redirect: ['userService','toState','toStateParams',  
               function(userService, toState, toStateParams){
                    //... code using toState and toStateParams
               }]
          }

You can check this JSFiddle for the sample code.


Don't just learn with uswork with us!

Your curious mind would be right at home at Summa. Check out our current postings to see what awesome opportunities await you.

Javier Ochoa
ABOUT THE AUTHOR

Senior Technical Consultant