Getting the name of a state in its `onEnter` hook

8.1k Views Asked by At

I'm building an application where I want to toggle a property in a service the moment a user enters and leaves a route. To do this I need to know about the state's name in the onEnter and onExit hooks. This is relatively easy for the onExit hook since I can just inject the $state service and read the name of the current state. But since the current state has not been set yet when the onEnter hook is called there is no way of knowing what the state we're transitioning to.

I still need to to have fine control over other parts of the state so I'd rather not have any for loops. I'm looking for a way to be able to pass the onEnter function to the state, whilst still retrieving the state's name inside of the function itself.

Here is the code I've written:

function onEnter($state, Steps) {
  var stateName = $state.current.name; // Not possible. The current state has not been set yet! 
  var step = Steps.getByStateName(stateName);

  step.activate();
}

function onExit($state, Steps) {
  var stateName = $state.current.name; // No problem. We know about the state. 
  var step = Steps.getByStateName(stateName);

  step.deactivate();
}

$stateProvider
  .state('step1', {
    url: '/step1',
    templateUrl: 'templates/step1.html',
    controller: 'StepOneController',
    onEnter: onEnter,
    onExit: onExit
  });

My solution I'm using for now is to use a factory to create context for the onEnter function passed to the state. This is far from ideal because I still need to pass the state's name to it.

Here is an example of said workaround:

function onEnterFactory(stateName) {
  return function onEnter(Steps) {
    var step = Steps.getByStateName(stateName);

    step.activate();
  }
}

$stateProvider
  .state('step1', {
    url: '/step1',
    templateUrl: 'templates/step1.html',
    controller: 'StepOneController',
    onEnter: onEnterFactory('step1')
  });
6

There are 6 best solutions below

3
On BEST ANSWER

Use this in onEnter onExit hooks. onEnter is invoked by following command:

$injector.invoke(entering.self.onEnter, entering.self, entering.locals.globals);

The second paramater of $injector.invoke is the value of this for the function it calls. So your code should look as follows:

function onEnter(Steps) {
  var stateName = this.name; 
  var step = Steps.getByStateName(stateName);

  step.activate();
}

function onExit(Steps) {
  var stateName = this.name;
  var step = Steps.getByStateName(stateName);

  step.deactivate();
}

$stateProvider
  .state('step1', {
    url: '/step1',
    templateUrl: 'templates/step1.html',
    controller: 'StepOneController',
    onEnter: onEnter,
    onExit: onExit
  });

Here is a working example of accessing a state's name in the onEnter and onExit hooks:

angular.module('myApp', ['ui.router'])

.config(function($stateProvider) {
  function onEnter() {
    console.log('Entering state', this.name);
  }

  function onExit() {
    console.log('Exiting state', this.name);
  }

  $stateProvider.state('state-1', {
    url: '/state-1',
    template: '<p>State 1</p>',
    onEnter: onEnter,
    onExit: onExit
  }).state('state-2', {
    url: '/state-2',
    template: '<p>State 2</p>',
    onEnter: onEnter,
    onExit: onExit
  });
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.6/angular.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular-ui-router/0.3.1/angular-ui-router.js"></script>

<div ng-app="myApp">
  <nav>
    <a ui-sref="state-1">State 1</a>
    <a ui-sref="state-2">State 2</a>
  </nav>

  <div ui-view></div>
</div>

1
On

You already know which state it will be, because you define it in the .state('statename',. To not write the same name twice, you can define the state variables beforehand:

var steps = ["step1", "step2", "step3"]; // or, something like Steps.getSteps()

$stateProvider
    .state(steps[0], {
        url: '/step1',
        templateUrl: 'templates/step1.html',
        controller: 'StepOneController',
        onEnter: function(Steps) {
            var stateName = steps[0];
            var step = Steps.getByStateName(stateName);

            step.activate();
        },
        onExit: function($state, Steps) {
            var stateName = $state.current.name; // No problem. We know about the state. 
            var step = Steps.getByStateName(stateName);
            step.deactivate();
        }
    });

You can even make it dynamic this way:

var steps = [
    { name: "step1", url: "/step1" },
    { name: "step2", url: "/step2" },
    { name: "step3", url: "/step3" }
]; // Or something like Steps.getSteps();

for (var i = 0; i < states.length; i++) {
    var state = steps[i];
    $stateProvider.state(state.name,
        url: state.url,
        templateUrl: "templates/" + state.name + ".html",
        onEnter: function(Steps) {
            var step = Steps.getByStateName(state.name);
            step.activate();

            // or just: state.activate();
        },
        onExit: function($state, Steps) {
            var stateName = $state.current.name; // No problem. We know about the state. 
            var step = Steps.getByStateName(stateName);

            step.deactivate();
        }
}
0
On

In one of my projects we used something like this

app.run(function($rootScope, $state, $location) {
    $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams,
    fromState) {
    $state.previous = fromState;
  });

to remember the previous state. But you might as well remember the new state and store the information somewhere.

0
On

You could extend your factory solution a little bit and make it more flexible.

Maybe have a provider that reacts to the state changes.
Then you could just inject this provider/service to onEnter function or where-ever you may need it.

Related plunker here http://plnkr.co/edit/6Ri2hE

angular.module('app', ['ui.router'])
  .provider('myState', function myStateProvider() {
    var state;

    this.onEnter = function() {
      console.log('provider.onEnter', state);
    };

    this.$get = function($rootScope) {
      var myState = {};

      myState.initialize = function() {
        $rootScope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams) {
          state = toState;
        });
      };

      myState.getState = function() {
        return state;
      };

      return myState;      
    };
  }) 

  .config(function($stateProvider, myStateProvider) {
    $stateProvider
      .state('step1', {
        url: '/step1',
        template: '<div>step1 template</div>',
        controller: function() {},
        onEnter: myStateProvider.onEnter  // usage example
      })
      .state('step2', {
        url: '/step2',
        template: '<div>step2 template</div>',
        controller: function() {},
        onEnter: function(myState) {     // other usage example 
          console.log('state.onEnter', myState.getState());
        }
      });
  })

  .run(function(myState) {
    myState.initialize();
  });

<a ui-sref="step1">state:step1</a>
<a ui-sref="step2">state:step2</a>
<div ui-view></div>

This would console.log() the following, if links are clicked sequentially.

imgur

1
On

Maybe you can use resolve,

$stateProvider
  .state('step1', {
   url: '/step1',
   templateUrl: 'templates/step1.html',
   controller: 'StepOneController',
   resolve: {
       onenter : function( Steps ) {
          // use this.self.name to access state name
          var step = Steps.getByStateName(this.self.name);
          step.activate();
       }
   } 
} );

If the above this approach seems unclean then maybe one can use decorator to populate current state.

angular.config(function($provide) {
  $provide.decorator('$state', function($delegate, $rootScope) {
    $rootScope.$on('$stateChangeStart', function(event, state) {
      $delegate.next = state;
    });
    return $delegate;
  });
});

State will be available in $state.next inside resolve function.

0
On

Add a 'name' property naming the state:

$stateProvider
  .state('step1', {
    name: 'step1' // <- property naming the state
    url: '/step1',
    templateUrl: 'templates/step1.html',
    controller: 'StepOneController',
    onEnter: onEnter
  });

The route's name is then accessible by this.name in the onEnter callback:

function onEnter() {
  var stateName = this.name; // <- Retrieve the state's name from its configuration
  var step = Steps.getByStateName(stateName);
  step.activate();
}

To not write the same state name twice, you could start by defining the states in a separate object and enriching the states with their name before adding them to the $stateProvider:

var routes = {
  "step1": {
    url: '/step1',
    templateUrl: 'templates/step1.html',
    controller: 'StepOneController',
    onEnter: onEnter
  }
};

for(var routeName in routes) {
      var route = routes[routeName];
      // Enrich route with its name before feeding 
      // it to the $stateProvider
      route.name = routeName;
      $stateProvider.state(route.name, route);
}