bacon_js_hero

About a month ago, we updated the entire Flowdock website and started using the controversial Rails feature, Turbolinks. Turbolinks changes the way a website works in a subtle way: instead of doing full page loads when navigating the site, it uses AJAX to replace only the body of the page with new HTML from the server. This can speed up navigating on your site, especially if you have a lot of JavaScript and CSS, since the browser does not have to fetch those assets and parse them on every page load.

Turbolinks & DOM events

Since most modern websites rely on JavaScript to work, loading pages using AJAX causes a problem: the DOMContentLoaded event (or any library functions that rely on it, like jQuery.ready) won’t be triggered on subsequent page loads, resulting in your pages not getting initialized properly.

Turbolinks has a bunch of additional events to help with the AJAX-based page load cycle. The most important ones are page:change and page:before-change. They are both available on the document element.

The page:change event is triggered both when the page has been parsed and changed to the new version as well as on DOMContentLoaded.

The page:before-change event is triggered immediately after a Turbolinks-enabled link has been clicked.

Aside from the page load lifecycle, there are a few other smaller issues. Since the browser doesn’t get to start from scratch on each page load, memory leaks and event listener management become potential problems. With Turbolinks, we need to be a bit more careful about what’s happening.

Bacon.js to the rescue!

Bacon.js is a functional reactive programming library written in JavaScript. If you’re not familiar with FRP, take a look at our Bacon.js primer.

In a nutshell, Bacon.js is all about streams of events. Event streams are a lot more powerful than just plain events, since they can be mutated, combined, filtered, etc. Streams can also be subscribed to, which is essentially the same as adding an event listener:

# These are roughly equivalent
$('.myElement').on('click', doSomething)
$('.myElement').asEventStream('click').onValue(doSomething)

In both cases the doSomething handler gets called on every click on .myElement with the event object as a parameter.

The big difference between using Bacon.js streams and plain jQuery is that with Bacon.js we can use functional reactive programming patterns and create event streams that are automatically cleaned up after a certain event:

# Examples are in CoffeeScript for convenience

# Create a stream of page:before-change events and
# map every event to true. This means the listener
# function will get `true` as a parameter every time.
beforeChange = $(document).asEventStream("page:before-change").map(true)

# Create a stream of all page:change events and
# map every event to false.
pageLoad = $(document).asEventStream("page:change").map(false)

# Initialize sidebar toggling on every page load.
pageLoad.onValue ->
  $('.openSidebar').asEventStream('click')
    .takeUntil(beforeChange)
    .onValue(toggleSidebar)

So, for every event in the pageLoad stream (i.e. on page:change events), create a stream of click events on the .openSidebar element and listen to them only until the next event in the beforeChange stream.

If you’re wondering why we map the values to booleans, we’ll get back to that later.

The equivalent code using plain jQuery would be:

$(document).on 'page:change', ->
  $('.open-sidebar').on('click', toggleSidebar)
  $(document).once 'page:before-change', ->
    $('.open-sidebar').off('click', toggleSidebar)

Now you’re probably thinking “How’s this supposed to be better and easier?” or “I could just use live selectors on document!”. First, Bacon.js streams created with .takeUntil are cleaned up automatically and are truly fire-and-forget: you really don’t need to care about unbinding things manually. Second, binding every possible listener to document and using event delegation or live selectors is guaranteed to hinder performance (at least if you have things like scroll listeners or other performance intensive event listeners). You’re also going to run into cases where a stale event handler that was meant for a previous page starts messing with your current page.

A real world example is the flowdock.com front page. At the top, there’s a navigation bar that becomes visible when you start scrolling up. The logic that defines how it’s shown is pretty complex and outside the scope of this article, but the main issue is that we need to listen to the window scroll and resize events and act accordingly. Having complex JavaScript running when you scroll can be a big hit on performance, so we want to make sure that we do it only when necessary. Also, there are animations on the front page that only start when you see them, and to trigger that behavior, we need to check the scroll position. These are all cases where it’s important to bind the events when the page is loaded and make sure they get unbound when navigating away.

Next, some more complex issues that are just plain easy to solve with Bacon.js.

Loading indicators

Let’s get back to why we added the boolean mappings to the event streams in the previous example. Unfortunately, when you use Turbolinks, the AJAX calls won’t spin the browser’s loading indicator. What can we do when a user with high network latency clicks a link and nothing seems to happen?

Using the Bacon.js streams from the previous examples, we can easily add a loading indicator to the page. We’re going to use another type of observable from Bacon.js: Property. A property is like an event stream, except it has a concept of current state.

So, to create a property that knows when the page is loading, we need to combine the event streams beforeChange (which emits true values) and pageChange (which emits false values). A property can be created from an event stream with .toProperty(initialValue), which also specifies what the initial value is (false in our case).

loading = pageLoad.merge(beforeChange).toProperty(false)

What follows then is a bit of Bacon.js wizardry. We want to add the loading indicator to the page only if the request takes more than 200ms. Fast page loads should seem instant, and they should not flash the loading indicator. We take the loading stream that emits true when the new page request starts and false when the new page is completed and add 200ms of delay. Because we want to know if the page is still loading after 200ms, we do a logical and operation with the original undelayed stream, and finally use Bacon.js’s assign, which basically ends up calling $("html").toggleClass("loading", event) with each boolean event.

loading.delay(200).and(loading).assign $("html"), "toggleClass", "loading"

This example nicely illustrates, how using Bacon.js streams and properties frees us from the need to manage state manually, and instead lets us concentrate on how the state should change.