Rails: Building a single-page Backbone app without sacrificing Turbolinks.

Part 1: Dealing with conflicting use of the browser history.


The final, annotated source for both parts of this blog post is available as a gist here: https://gist.github.com/JonMidhir/129d270265848544f003
Part 2 is available here.

If you’re using Rails to build your application it seems like it shouldn’t be a big deal to have a single, highly interactive page use a front-end JavaScript framework like Backbone.js and the rest fall back to HTML. But Turbolinks can make this a little tricky, since both interact with the browser’s history and you no longer have full page reloads to rely on if you forget to release parts of your Backbone app.

A lot of the time the advice seems to be ‘don’t bother’.

Yet even the showcase app for Rails 4 — Basecamp — reportedly uses Backbone.js solely for its calendar feature. Why should you have to give up the speed benefits of Turbolinks on your site to use Backbone on one page?

Using Turbolinks’s custom page events and replaceState it’s possible to get almost all the benefits of both.

Loading Your Backbone App

You probably have a setup that looks like this:

window.MyApp =
Models: {}
Collections: {}
Views: {}
Routers: {}

initialize: ->
new MyApp.Routers.Items()
Backbone.history.start()
$(document).ready ->
MyApp.initialize()

With Turbolinks enabled your app will be initialized on the first page load and will hang around while the visitor navigates your site, hardly ideal if the app attaches to DOM elements or bootstraps data that might not be present on the first page.

Fortunately, Turbolinks provides a number of custom page events that we can hook into to initialize the app on every page. One of these events is the page:change event, which fires when the page has been fully loaded or replaced by Turbolinks, whether from the server or from its own page cache. This is important, because we need it to fire when the user navigates to the page through the browser history too (we’ll get to that later).

# $(document).ready ->
$(document).on 'page:change', ->
MyApp.initialize()

We still have a problem with the app loading on every page rather than just the one(s) we want. One way to fix this is to check for the presence of a particular DOM element.

shouldLoadApp = -> $('#items').size() > 0
$(document).on 'page:change', ->
MyApp.initialize() if shouldLoadApp()

Great! Now the app will load when the user is on any page with the #items element.

Dealing With The Past

Say we’ve been using our Backbone app and we navigate to another part of our site. The last page state was replaced by Backbone.history and the new one by Turbolinks. That’s fine while we’re navigating forwards using links on the page, but what if we hit the back button now?

Well, nothing really. We get no Turbolinks page:change event and our Backbone app doesn’t initialize. This is because Turbolinks adds an attribute to the state object that isn’t present when Backbone has replaced the state.

It turns out the source of both Turbolinks and Backbone is very readable and well annotated (the Backbone source even has its own site). It’s easy to get an idea of how each uses the browser’s history and this is key to tricking Turbolinks into stepping in when we need it to.

Turbolinks doesn’t currently expose a public API method to replace the state but it’s easy to mock it. And we can do this before we leave the page featuring the Backbone app, using the page:before-change event.

$(document).on 'page:before-change', ->
if Backbone.History.started and Turbolinks?.supported
locHref = window.location.href
title = window.title
window.history.replaceState {turbolinks: true, url: locHref}, title, locHref

Now we’ve ensured that when the visitor navigates away, the history entry for the page will be overwritten to engage Turbolinks if invoked. We’ve also ensured that the state is only replaced if the Backbone app is running.

Note: I think it’d be more appropriate to perform this later in the page change lifecycle but other page events; such as page:fetch, appear to be too late. Worst case scenario the user stops navigation before it’s complete and continues to navigate the Backbone app instead, resulting in a reload of the app if they happen to navigate back to this place in history.

Here it is so far (gist):

window.MyApp =
Models: {}
Collections: {}
Views: {}
Routers: {}

initialize: ->
new MyApp.Routers.Items()
Backbone.history.start()
shouldLoadApp = -> $('#items').size() > 0
$(document).on 'page:before-change', ->
if Backbone.History.started and Turbolinks?.supported
locHref = window.location.href
title = window.title
window.history.replaceState {turbolinks: true, url: locHref}, title, locHref

$(document).on 'page:change', ->
MyApp.initialize() if shouldLoadApp()

Part 2 covers how to deal with the (almost inevitable) zombie objects that get left behind as you cross the Turbolinks/Backbone boundary and where in the page lifecycle to clean them up.

Notes

  1. Ryan MacInnes made good headway originally in this blog post: http://www.goddamnyouryan.com/blog/rails-4-turbolinks-and-backbone
  2. I have only tested this approach with Backbone.history’s pushState set to false.
  3. Got advice? This is a moving target, please get in touch — @midhir.
Like what you read? Give Jon Hope a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.