How do I get the Back Button to work with an AngularJS ui-router state machine?

JavascriptAngularjsCoffeescriptAngular Ui-Router

Javascript Problem Overview


I have implemented an angularjs single page application using ui-router.

Originally I identified each state using a distinct url however this made for unfriendly, GUID packed urls.

So I have now defined my site as a much simpler state-machine. The states are not identified by urls but are simply transitioned to as required, like this:

Define Nested States

angular
.module 'app', ['ui.router']
.config ($stateProvider) ->
	$stateProvider
	.state 'main', 
		templateUrl: 'main.html'
		controller: 'mainCtrl'
		params: ['locationId']

	.state 'folder', 
		templateUrl: 'folder.html'
		parent: 'main'
		controller: 'folderCtrl'
		resolve:
			folder:(apiService) -> apiService.get '#base/folder/#locationId'

Transition to a Defined State

#The ui-sref attrib transitions to the 'folder' state

a(ui-sref="folder({locationId:'{{folder.Id}}'})")
	| {{ folder.Name }}

This system works very well and I love its clean syntax. However, as I am not using urls the back button does not work.

How do I keep my neat ui-router state-machine but enable the back button functionality?

Javascript Solutions


Solution 1 - Javascript

Note

The answers that suggest using variations of $window.history.back() have all missed a crucial part of the question: How to restore the application's state to the correct state-location as the history jumps (back/forward/refresh). With that in mind; please, read on.


Yes, it is possible to have the browser back/forward (history) and refresh whilst running a pure ui-router state-machine but it takes a bit of doing.

You need several components:

  • Unique URLs. The browser only enables the back/forward buttons when you change urls, so you must generate a unique url per visited state. These urls need not contain any state information though.

  • A Session Service. Each generated url is correlated to a particular state so you need a way to store your url-state pairs so that you can retrieve the state information after your angular app has been restarted by back / forward or refresh clicks.

  • A State History. A simple dictionary of ui-router states keyed by unique url. If you can rely on HTML5 then you can use the HTML5 History API, but if, like me, you can't then you can implement it yourself in a few lines of code (see below).

  • A Location Service. Finally, you need to be able manage both ui-router state changes, triggered internally by your code, and normal browser url changes typically triggered by the user clicking browser buttons or typing stuff into the browser bar. This can all get a bit tricky because it is easy to get confused about what triggered what.

Here is my implementation of each of these requirements. I have bundled everything up into three services:

The Session Service

class SessionService

	setStorage:(key, value) ->
		json =  if value is undefined then null else JSON.stringify value
		sessionStorage.setItem key, json

	getStorage:(key)->
		JSON.parse sessionStorage.getItem key

	clear: ->
		@setStorage(key, null) for key of sessionStorage

	stateHistory:(value=null) ->
		@accessor 'stateHistory', value

	# other properties goes here

	accessor:(name, value)->
		return @getStorage name unless value?
		@setStorage name, value

angular
.module 'app.Services'
.service 'sessionService', SessionService

This is a wrapper for the javascript sessionStorage object. I have cut it down for clarity here. For a full explanation of this please see: How do I handle page refreshing with an AngularJS Single Page Application

The State History Service

class StateHistoryService
	@$inject:['sessionService']
	constructor:(@sessionService) ->

	set:(key, state)->
		history = @sessionService.stateHistory() ? {}
		history[key] = state
		@sessionService.stateHistory history

	get:(key)->
		@sessionService.stateHistory()?[key]

angular
.module 'app.Services'
.service 'stateHistoryService', StateHistoryService

The StateHistoryService looks after the storage and retrieval of historical states keyed by generated, unique urls. It is really just a convenience wrapper for a dictionary style object.

The State Location Service

class StateLocationService
	preventCall:[]
	@$inject:['$location','$state', 'stateHistoryService']
	constructor:(@location, @state, @stateHistoryService) ->

	locationChange: ->
		return if @preventCall.pop('locationChange')?
		entry = @stateHistoryService.get @location.url()
		return unless entry?
		@preventCall.push 'stateChange'
		@state.go entry.name, entry.params, {location:false}

	stateChange: ->
		return if @preventCall.pop('stateChange')?
		entry = {name: @state.current.name, params: @state.params}
		#generate your site specific, unique url here
		url = "/#{@state.params.subscriptionUrl}/#{Math.guid().substr(0,8)}"
		@stateHistoryService.set url, entry
		@preventCall.push 'locationChange'
		@location.url url

angular
.module 'app.Services'
.service 'stateLocationService', StateLocationService

The StateLocationService handles two events:

  • locationChange. This is called when the browsers location is changed, typically when the back/forward/refresh button is pressed or when the app first starts or when the user types in a url. If a state for the current location.url exists in the StateHistoryService then it is used to restore the state via ui-router's $state.go.

  • stateChange. This is called when you move state internally. The current state's name and params are stored in the StateHistoryService keyed by a generated url. This generated url can be anything you want, it may or may not identify the state in a human readable way. In my case I am using a state param plus a randomly generated sequence of digits derived from a guid (see foot for the guid generator snippet). The generated url is displayed in the browser bar and, crucially, added to the browser's internal history stack using @location.url url. Its adding the url to the browser's history stack that enables the forward / back buttons.

The big problem with this technique is that calling @location.url url in the stateChange method will trigger the $locationChangeSuccess event and so call the locationChange method. Equally calling the @state.go from locationChange will trigger the $stateChangeSuccess event and so the stateChange method. This gets very confusing and messes up the browser history no end.

The solution is very simple. You can see the preventCall array being used as a stack (pop and push). Each time one of the methods is called it prevents the other method being called one-time-only. This technique does not interfere with the correct triggering of the $ events and keeps everything straight.

Now all we need to do is call the HistoryService methods at the appropriate time in the state transition life-cycle. This is done in the AngularJS Apps .run method, like this:

Angular app.run

angular
.module 'app', ['ui.router']
.run ($rootScope, stateLocationService) ->

	$rootScope.$on '$stateChangeSuccess', (event, toState, toParams) ->
		stateLocationService.stateChange()

	$rootScope.$on '$locationChangeSuccess', ->
		stateLocationService.locationChange()

Generate a Guid

Math.guid = ->
	s4 = -> Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
	"#{s4()}#{s4()}-#{s4()}-#{s4()}-#{s4()}-#{s4()}#{s4()}#{s4()}"

With all this in place, the forward / back buttons and the refresh button all work as expected.

Solution 2 - Javascript

app.run(['$window', '$rootScope', 
function ($window ,  $rootScope) {
  $rootScope.goBack = function(){
    $window.history.back();
  }
}]);

<a href="#" ng-click="goBack()">Back</a>

Solution 3 - Javascript

After testing different proposals, I found that the easiest way is often the best.

If you use angular ui-router and that you need a button to go back best is this:

<button onclick="history.back()">Back</button>

or

<a onclick="history.back()>Back</a>

// Warning don't set the href or the path will be broken.

Explanation: Suppose a standard management application. Search object -> View object -> Edit object

Using the angular solutions From this state :

> Search -> View -> Edit

To :

> Search -> View

Well that's what we wanted except if now you click the browser back button you'll be there again :

> Search -> View -> Edit

And that is not logical

However using the simple solution

<a onclick="history.back()"> Back </a>

from :

> Search -> View -> Edit

after click on button :

> Search -> View

after click on browser back button :

> Search

Consistency is respected. :-)

Solution 4 - Javascript

If you are looking for the simplest "back" button, then you could set up a directive like so:

	.directive('back', function factory($window) {
	  return {
		restrict   : 'E',
		replace    : true,
		transclude : true,
		templateUrl: 'wherever your template is located',
		link: function (scope, element, attrs) {
		  scope.navBack = function() {
		    $window.history.back();
		  };
		}
      };
	});

Keep in mind this is a fairly unintelligent "back" button because it is using the browser's history. If you include it on your landing page, it will send a user back to any url they came from prior to landing on yours.

Solution 5 - Javascript

browser's back/forward button solution
I encountered the same problem and I solved it using the popstate event from the $window object and ui-router's $state object. A popstate event is dispatched to the window every time the active history entry changes.
The $stateChangeSuccess and $locationChangeSuccess events are not triggered on browser's button click even though the address bar indicates the new location.
So, assuming you've navigated from states main to folder to main again, when you hit back on the browser, you should be back to the folder route. The path is updated but the view is not and still displays whatever you have on main. try this:

angular
.module 'app', ['ui.router']
.run($state, $window) {
 
     $window.onpopstate = function(event) {

	    var stateName = $state.current.name,
	        pathname = $window.location.pathname.split('/')[1],
            routeParams = {};  // i.e.- $state.params

	    console.log($state.current.name, pathname); // 'main', 'folder'
	
        if ($state.current.name.indexOf(pathname) === -1) {
		    // Optionally set option.notify to false if you don't want 
		    // to retrigger another $stateChangeStart event
		    $state.go(
              $state.current.name, 
              routeParams,
              {reload:true, notify: false}
            );
        }
    };
}

back/forward buttons should work smoothly after that.

note: check browser compatibility for window.onpopstate() to be sure

Solution 6 - Javascript

Can be solved using a simple directive "go-back-history", this one is also closing window in case of no previous history.

Directive usage

<a data-go-back-history>Previous State</a>

Angular directive declaration

.directive('goBackHistory', ['$window', function ($window) {
    return {
        restrict: 'A',
        link: function (scope, elm, attrs) {
            elm.on('click', function ($event) {
                $event.stopPropagation();
                if ($window.history.length) {
                    $window.history.back();
                } else {
                    $window.close();  
                }
            });
        }
    };
}])

Note: Working using ui-router or not.

Solution 7 - Javascript

The Back button wasn't working for me as well, but I figured out that the problem was that I had html content inside my main page, in the ui-view element.

i.e.

<div ui-view>
     <h1> Hey Kids! </h1>
     <!-- More content -->
</div>

So I moved the content into a new .html file, and marked it as a template in the .js file with the routes.

i.e.

   .state("parent.mystuff", {
		url: "/mystuff",
		controller: 'myStuffCtrl',
		templateUrl: "myStuff.html"
	})

Solution 8 - Javascript

history.back() and switch to previous state often give effect not that you want. For example, if you have form with tabs and each tab has own state, this just switched previous tab selected, not return from form. In case nested states, you usually need so think about witch of parent states you want to rollback.

This directive solves problem

angular.module('app', ['ui-router-back'])

<span ui-back='defaultState'> Go back </span>

It returns to state, that was active before button has displayed. Optional defaultState is state name that used when no previous state in memory. Also it restores scroll position

Code

class UiBackData {
    fromStateName: string;
    fromParams: any;
    fromStateScroll: number;
}

interface IRootScope1 extends ng.IScope {
    uiBackData: UiBackData;
}

class UiBackDirective implements ng.IDirective {
    uiBackDataSave: UiBackData;

    constructor(private $state: angular.ui.IStateService,
        private $rootScope: IRootScope1,
        private $timeout: ng.ITimeoutService) {
    }

    link: ng.IDirectiveLinkFn = (scope, element, attrs) => {
        this.uiBackDataSave = angular.copy(this.$rootScope.uiBackData);

        function parseStateRef(ref, current) {
            var preparsed = ref.match(/^\s*({[^}]*})\s*$/), parsed;
            if (preparsed) ref = current + '(' + preparsed[1] + ')';
            parsed = ref.replace(/\n/g, " ").match(/^([^(]+?)\s*(\((.*)\))?$/);
            if (!parsed || parsed.length !== 4)
                throw new Error("Invalid state ref '" + ref + "'");
            let paramExpr = parsed[3] || null;
            let copy = angular.copy(scope.$eval(paramExpr));
            return { state: parsed[1], paramExpr: copy };
        }

        element.on('click', (e) => {
            e.preventDefault();

            if (this.uiBackDataSave.fromStateName)
                this.$state.go(this.uiBackDataSave.fromStateName, this.uiBackDataSave.fromParams)
                    .then(state => {
                        // Override ui-router autoscroll 
                        this.$timeout(() => {
                            $(window).scrollTop(this.uiBackDataSave.fromStateScroll);
                        }, 500, false);
                    });
            else {
                var r = parseStateRef((<any>attrs).uiBack, this.$state.current);
                this.$state.go(r.state, r.paramExpr);
            }
        });
    };

    public static factory(): ng.IDirectiveFactory {
        const directive = ($state, $rootScope, $timeout) =>
            new UiBackDirective($state, $rootScope, $timeout);
        directive.$inject = ['$state', '$rootScope', '$timeout'];
        return directive;
    }
}

angular.module('ui-router-back')
    .directive('uiBack', UiBackDirective.factory())
    .run(['$rootScope',
        ($rootScope: IRootScope1) => {

            $rootScope.$on('$stateChangeSuccess',
                (event, toState, toParams, fromState, fromParams) => {
                    if ($rootScope.uiBackData == null)
                        $rootScope.uiBackData = new UiBackData();
                    $rootScope.uiBackData.fromStateName = fromState.name;
                    $rootScope.uiBackData.fromStateScroll = $(window).scrollTop();
                    $rootScope.uiBackData.fromParams = fromParams;
                });
        }]);

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionbiofractalView Question on Stackoverflow
Solution 1 - JavascriptbiofractalView Answer on Stackoverflow
Solution 2 - JavascriptGuillaume MasséView Answer on Stackoverflow
Solution 3 - JavascriptbArraxasView Answer on Stackoverflow
Solution 4 - Javascriptdgrant069View Answer on Stackoverflow
Solution 5 - Javascriptjfab fabView Answer on Stackoverflow
Solution 6 - JavascripththetiotView Answer on Stackoverflow
Solution 7 - JavascriptCodyBugsteinView Answer on Stackoverflow
Solution 8 - JavascriptSelView Answer on Stackoverflow