Friday, May 3, 2013

Managing history in Backbone widgets with jQuery BBQ

In my last blog post, I talked about the two different Backbone architectures we're experimenting with at Coursera: 1) single page web apps, where Backbone takes care of serving particular views for given URLs, and 2) JS widgets, where we write DIVs with particular data attributes into our HTML, and a JS module finds all of them on the page and turns them into Backbone views.


The Widget Approach

We are using this approach for our discussion forums. We have widgets for displaying lists of threads, rendering a single thread, displaying an entire forum with multiple thread lists inside it, and more. We can potentially mash up a few widgets on the same page, if we want, because they each encapsulate all of their functionality inside them.

For example, here's the bit of code that creates our forum widget:

$('[data-coursera-forum-widget]').each(function() {
  var forumId = $(this).attr('data-forum-id');

  var forum = new ForumModel({
    id: forumId
  });
  new ForumView({
    el: $(this)[0],
    model: forum
  });
});

The Problem

There was one problem with this approach, however: users kept losing their state in the widgets. For example, when a TA was paging through a long list of threads, and then they clicked away to visit one and came back, the widget would forget that it was on that page and they'd have to start from the beginning. When the forums were originally written, in the classical Web 1.0 architecture, the state was always stored in the query parameters of the URL, but now, since we are in JS-land and no longer need to change the URL to change the content of the page, we lost the URL-managed state.

That meant that we lost the ability for users to use the back button through the states of one widget, to go forward to a completely different page and back to the previous state, and to bookmark the state. Once I got enough reports from users who missed those abilities (mostly from our super users, who consider the forums to be their inbox), I realized it was time to take on this problem.


Possible Solutions

There were a few solutions that I considered and talked through with my colleagues:

  • Remember their previous state in cookies or localStorage, and always restore it from there. That wouldn't easily get me back button support, however, and it may have been odd for the user to open the forum in a new tab and see that same state.
  • Open all the links in a new tab, so that they never left the page and lost their state. Yes, I admit, this was a non-ideal solution, and I did try it for a few hours but I quickly remembered that I should not be the one deciding that the user wants those links in a new tab. Also, that wouldn't solve the problem of back button through widget state.
  • Move to the single page web app approach, and let the Backbone router manage the history. That would mean losing the mashability of my widgets, the ability to put any combination of widgets on the same page, and I'm not ready to give that up.
  • Use a JS library to store each widget's history in the URL hash, and use the hashchange event (and fallback implementations) to support the back button.

As you can guess from the title of the post, I went with the final approach. It's the only one that solved all of the users problems and let me keep my widget approach - plus it's a tried and true technique.


jQuery BBQ + Backbone

There are a few libraries out there that manage history via the hash, from very simple (js-hash) to a sophisticated polyfill approach (Hasher). The one that I was most familiar with was jQuery BBQ, and its also the one that did everything I wanted and not too much more. Plus, its docs described exactly our scenario:

<Widget> Yo, hash, update my state parameters.
<Hash> No prob, dude, done. And you didn’t even have to know about that other widget’s parameter, I just merged them in there for you.
<Widget> There’s another widget?
<Widget2> Huh? Did someone say my name?

jQuery BBQ has a straightforward API - you can pushState an object that is merged into the current hash values, you can getState on a certain key, and you can listen to the hashchanged event. To make it easy for multiple Backbone views to manage their state independently of each other, I wrote a WidgetView class with functions that can set and get state scoped to just the widget, and can trigger the view with an event whenever the widget's state changed in the hash. Here's what that class looks like:

Once my Backbone view extends that class, it can check the initial state when the view is loaded, it can set the state when the user clicks around (like on the sort or page controls), and it can listen to the state changed event to decide how to change the UI.

Here's a slimmed down version of the ThreadsView that demonstrates that:



We'll see how this works out once we build out more widgets, but so far, it seems to be working well. Let me know in the comments what approach you've taken.

No comments: