← home← blog

Rewriting History with Backbone.js

13 Nov 2013

Controlling navigation in a large client-side application

As more and more applications are moving to the browser, we're now finding ourselves having to fix some of the already solved paradigms, ie. navigation.

Sending the user to a different URL is no problem, whether you're using hash based navigation or HTML5 PushState, this is probably going to be handled by your MV* framework of choice.

However…

The real fun happens when you want to start doing stuff like "in-app back buttons". We began this venture by simply using window.history.back() as a blanket solution. How naive we were.

For the most part, it works. But like all blanket solutions, you'll unstuck at some point because with large apps, you end up with a lot of custom requirements. We found that our main issue with a blanket window.history.back() solution was that we often didn't always want the user to be navigated back historically, but rather hierarchically. So going from "items/123" back to "items", is a hierarchical manoeuvre, but also could be a historical manoeuvre. So we needed more granular control of sending the user back to previous URLs. And often is the case, some wheels haven't been invented yet. So, what I'm showing you here isn't revolutionary, but is a strategy we've implemented at KashFlow and I wanted to share it with you all. I should point out, that because we're using Backbone.js here, this solution will be largely applicable to Backbone.js apps.

Tracking the user's movements

We start with extending and enhancing Backbone's standard Router, like so:

(function() {
  _.extend(Backbone.Router.prototype, Backbone.Events, {
    before: function() {},
    after: function() {},
    route: function(route, name, callback) {
      Backbone.history || (Backbone.history = new Backbone.History);
      if (!_.isRegExp(route)) route = this._routeToRegExp(route);
      if (!callback) callback = this[name];
      Backbone.history.route(route, _.bind(function(fragment) {
        var that = this;
        var args = this._extractParameters(route, fragment);
        if (_(this.before).isFunction()) {
          this.before.apply(this, args);
        }
        if (callback) callback.apply(that, args);
        if (_(this.after).isFunction()) {
          this.after.apply(this, args);
        }
      }, this));
    }
  });
}).call(this);

What this allows us to do is define a before() and after() function on our instantiated Router class. These functions will run before and after the user has been routed to a new URL, respectively.

With this in place we can define a storeRoute() method on our Router:

appRouter.js:

initialize: function(){
	this.history = [];
	this.ignore = false;
},

after: function(){
	this.storeRoute();
}

So, after every route the user takes, we're going to call our storeRoute() function. We've also declared a history array on initialization of our Router and an ignore property. You'll see why in a second.

Here is our storeRoute() function which is also in appRouter.js:

storeRoute: function(){	
	if (!app.router.ignore) {
		var re = /[a-zA-Z]+\/[\d|new]/; // matches, eg. "quotes/5" or "quotes/new"
		if (!this.history.length) {
			var parts = Backbone.history.fragment.split('/'),
				len = parts.length,
				i = 0;
	
			while (i < len) {
				this.history.push(parts.slice(0, i + 1).join('/'));
				i++;
			}
		} else {
			this.history.push(Backbone.history.fragment);
		}
	} else {
		app.router.ignore = false;
	}
}

So, quite a bit going on here. Firstly, we check to see whether we should ignore the user's route or not. There may be certain situations where you might not want to log where the user has been, for example if you've sent them to a temporary state URL.

The next part deals with rewriting history or faking the user's journey to a URL. This is for the occasions where the user has either come directly to a URL or they've refreshed the page on a particular URL, so their in-memory history would be empty. Now, we've decided, in our application, that if a user goes directly to a URL, like "/quotes/123", we need to tell the Router, and inparticular, the history array, how they've got there. So we match these kinda of URLs with a regex and loop over the URL, passing each part of the URL into the history array. For example, if the user goes to "/quotes/123", we process this and get left with a history array containing two elements: quotes and quotes/123. You'll see why this is important in a second.

Sending them back to where they came from

We then move on to our next Router function, the previous(), again, in appRouter.js:

previous: function(options) {
	app.router.ignore = options.ignore || false;
	if (this.history.length > 1) {
		this.navigate(this.history[this.history.length - 2], {
			trigger: true
		});
		this.history = this.history.slice(0, -1);
	}
}

So, we can hook up an event listener to our "in-app back button" and set the handler to be app.router.previous(). When called, we can safely, as we've checked the length property of the history array, use Backbone's navigate() function to send the user to the correct URL. We then remove the last element from the history array, ie. the place they've clicked "Back" from. This is because we don't really want to be storing their "previous URL before clicking back" URL as they'll be stuck in an infinite loop between the two URLs!

Reinventing the wheel

With Backbone.js you tend to have to create a few concepts on your own as it really is a base library that you end up building an application framework on top of. So I'm really keen to hear of your similar issues, like the one above, that you've solved with different methods or whether the newer frameworks, ie. Angular or Ember, manage to alleviate these problems.

I'm available for hire

Hire me