Implementing microstates in Backbone.js

microstate

Wireframe made with Balsamiq, including four color-coded interactions which will be discussed in the post.

When developing a Backbone.js mobile web app recently, I needed to create a unique history state without a unique URL when opening an overlay. This was done to allow the user to hit back in the browser and be returned to the view which launched the overlay. While solving this problem, I landed upon a concept of microstates which I thought might be of interest to other web app developers.

In this post I will first give an overview of the HTML5 History API, then talk about how to create a microstate in a Backbone app, and conclude with the implications of exposing microstates.

Background on the HTML5 History API

Before I dive into microstates, I thought it would be helpful to give an overview of the History API. When implementing microstates, it is important to be familiar with two methods(history.pushState and history.back) and one event(popstate).

history.pushState

The history.pushState method adds a new entry to the browser history, associating a state object with a URL. Here is its method signature:

window.history.pushState(stateObject, stateTitle, url);

stateObject

Type: Serializable Object

This object represents the state which you wish to associate with a new URL in your app. It must be a serializable object, so it cannot contain functions.

stateTitle

Type: String

This string is currently ignored, so it is best to either pass an empty string or document.title.

url

Type: String

This is the URL that will be shown in the address bar of the browser after the update occurs. If the user refreshes, it is your responsibility to reproduce the current state using the url and stateObject.

history.back

The history.back method is essentially the programmatic way of clicking the back button in the browser. It is an asynchronous method and the only way to respond to it is by listening to the popstate event.

window.history.back();
console.log('This code will execute before the browser goes back one history entry.');

popstate

The popstate event is how you respond to state changes in a web app. It provides you a reference to the current pushState stateObject via event.state so that you can update the app to reflect the new state. In Backbone, you can make popstate a global event by triggering an event on the Backbone object and then listening to the popstate event in your view like so:

// Trigger popstate as a Backbone event
window.onpopstate = function (event) {
  Backbone.trigger('popstate', event);
};

// Listen to popstate in the ParentView and respond by either showing
// or hiding the overlay
var ParentView = Backbone.View.extend({

  initialize: function () {
    this.listenTo(Backbone, 'popstate', this.popstate);
  },

  popstate: function (event) {

    // If event.state is not null or
    // an empty {}, it is a microstate
    // that we pushed explicitly, so show the overlay
    if (event.state && _.keys(event.state).length > 0) {

      // more on this method below
      this.showOverlay(event.state, true);

    // If event.state is null or {}, that means
    // the state was never pushed(deep-link) or pushed
    // by Backbone with an empty state object
    } else {
      this.hideOverlay();
    }
  }
});

How Backbone uses the History API

Backbone uses the History API when run in modern browsers, and falls back to hash changes in older browsers. When using Backbone, you are provided the router.navigate method to update the state of your app to a new URL so that if the user refreshes their browser, they will see the same content, or if they hit the back button, they will be returned to the previous content. Backbone ensures unique url entries, and opts out of using the stateObject as seen in the below excerpt from the source code:

this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url);

Note the empty object literal as the first argument.

Because of this, Backbone relies on URLs to be unique, so a few lines above that code, the following code detects a duplicate URL fragment and exits early before pushing it:

if (this.fragment === fragment) return;

Implementing a microstate

Typically the standard Backbone routing serves all your needs as a web developer, but the overlay state I described earlier would have its own state without a new URL, making it impossible to use router.navigate. Below I will walk through how I implemented microstates in a parent view, overlay view, and router.

Implementing a microstate in a view

To create a microstate, I circumvented the Backbone Router and interacted with history.pushState directly. Here is some sample code for pushing a microstate into history:

var ParentView = Backbone.View.extend({

  // UI event which initially shows the overlay
  events: {
    'click .button': 'showOverlay'
  },

  // This method is augmented with a second parameter
  // to allow for the popstate handler to show the overlay
  // without pushing a new state(as seen in above popstate snippet)
  showOverlay: function (event, triggeredByPopState) {

    // Unless the user pressed forward in the browser and the overlay is being
    // shown via the popstate handler, add a new microstate in history
    if (!triggeredByPopstate) {

      // This object must be serializable, no functions allowed!
      var overlayState = {
        overlayType: 'locationFilter'
      };

      // In contrast with Backbone's `router.navigate`, this pushes a
      // unique stateObject and a duplicate URL. 
      window.history.pushState(overlayState, document.title, '');
    }

    // Now show the overlay
    this.subView.overlay.show();
  }
});

Implementing a microstate in the router

To accomodate microstates, I ended up keeping track of the hit routes in my app by using the backbone.routefilter plugin. Using the before method in the router, I could now determine if a newly pushed state was a microstate by comparing it with the previous URL and then exiting early if they match. Here is some sample router code to set this up:

var MyRouter = Backbone.Router.extend({

  initialize: function () {

    // When app initializes there are no hitRoutes
    this.hitRoutes = [];
  },

  // This function is called when a popstate event occurs before a route handler is invoked.
  // If false is returned, the route handler will not be called, if true, it will.
  before: function () {

    var currentUrl = window.location.pathname + window.location.search;

    // If the new state is a micro state,
    // don't process it via the corresponding route handler
    if (this.hitRoutes.length > 0 && 
        _.last(this.hitRoutes) === currentUrl) {
      return false;

    // If the user goes back, remove the last hitRoute so that 
    // hitRoutes has the correct record of history
    } else if (this.hitRoutes.length > 1 &&
               currentUrl === this.hitRoutes[this.hitRoutes.length - 2]) {
      this.hitRoutes.pop();

    // By default add the URL to the array of hit routes
    } else {
      this.hitRoutes.push(currentUrl);
    }

    return true;
  }
});

Pushing a new URL while in a microstate

For my use case, the microstate provided input which if submitted, resulted in a new URL being pushed. However, if I pushed the new URL outright, the microstate would persist in the browser history when I really wanted it to temporarily exist in front of the parent state. With no further code, the history would look like this:

parent state -> microstate -> new state

To resolve this I used the history.back method described earlier and listened to the popstate event before pushing the new URL, essentially short-circuiting the microstate. Here is some sample code to achive this:

var MyOverlayView = Backbone.View.extend({

  // UI event which submits the input data
  events: {
    'click .submit': 'submitData'
  },

  submitData: function () {

    // Go back asynchronously and alert the parent view
    window.history.back();
    this.trigger('submit');
  }
});

Here is how the parent view would respond to the submit:

var MyParentView = Backbone.View.extend({
  initialize: function () {
    this.subView.overlay = new OverlayView();
    this.listenTo(this.subView.overlay, 'submit', this.overlaySubmitted);
  },

  overlaySubmitted: function () { 

    // Wait until the browser goes back before pushing new state
    this.listenToOnce(Backbone, 'popstate', function () {
      App.Router.navigate('newState');
    });
  }
});

With this code, the history would look like this after submitting within the overlay:

parent state -> new state

Recovering from refreshes

If a user refreshes on a microstate, you will likely want to restore them to the parent state. My approach to solve this problem was to detect if the user refreshed in a microstate during the parent view initialization via history.event.state and call history.back() to restore them to the original parent URL. If you stored the state correctly, you should be able to replicate the microstate if user goes forward in their browser after a refresh. Here is some sample parent view code to achieve this:

var MyParentView = Backbone.View.extend({
  initialize: function () {

    // When initialized, check if the user refreshed
    // in a modal state, and if so bring them back to the
    // parent state
    if (window.history.event.state &&
        _.keys(window.history.event.state).length > 0) {
      window.history.back();
    }
  }
});

Implications of using microstates

I am excited by the potential of using microstates in web apps, particularly mobile web apps where screen real estate is a concern and navigating with the browser's back/forward is more compelling. By implementing microstates, the browser back/forward becomes first-class navigation and users can opt out of always having to use the HTML UI.

However, microstates come with complexity. Exposing first class browser back/forward navigation allows users to quickly navigate between states and dependending on how much animation is involved, a user might break the animation. Additionally, there is not support for the History API in browsers like IE8 & IE9 as well as some older Android browsers and Opera Mini, so depending on your user base, it might not make sense(check out Can I use for the full compatibility list). It may be worth making an abstraction for microstates which would eliminate much of the boilerplate code above and provide fallbacks for older browsers in order to reduce complexity.

Conclusion

While microstates appear simple in initial concept by allowing the user to back out of an overlay state, they are somewhat complex to implement in a robust way. I am curious to hear the community's thoughts on this subject, and I hope you walk away with some new knowledge about the History API as well as some thoughts about potentially leveraging microstates in your app.

cabin logo