Using events like a boss

You've been using events since you wrote your first view and got it to listen to a model's change event. But this chapter is about using events for creating a message bus for inter-component communication that will allow each piece (or module) or your app to work standalone.

Remember the Views chapter, “…they should still work, as in not throw errors, when rendered on their own”. In this chapter, we'll make the UI components interactible even, and insofar as they relate to other components, the interaction should produce no errors.

★ ★ ★

There's a number of pubsub libraries for JavaScript out there. If you're using Backbone, they're all a waste of time. The Backbone object itself works as an event hub that your components (views, models, collections, etc) can listen on, and since that object will always be visible to pretty much every component you write, there's no reason why you'd step out of it.

You can think of it as a global message exchange. You'll dump on it anything that's not of interest to an immediate module relationship. An immediate relationship is, for example, a view and its model. Now if for example your app flashes a message that stays visible for a few seconds every time a Book is created, then this flash component works a lot like an addon which isn't tied to the view that does the model creation, nor the model itself.

You want to be able to create books without getting an error complaining this “flash component” doesn't exist. So we should set up our Book model to broadcast a message when a new one is created:

class Book extends Backbone.Model
  initialize: ->
    @on 'change:id', ->
      Backbone.trigger 'books:created', @
var Book = Backbone.Model.extend({
  initialize: function() {
    this.on('change:id', function() {
      Backbone.trigger('books:created', this);
    }
  }
});

The component can then go:

class Flash extends Backbone.View
  initialize: ->
    @listenTo Backbone, 'books:created', @show

  show: (model) ->
    @model = model
    @render()
    # Display the message...
var Flash = Backbone.View.extend({
  initialize: function() {
    this.listenTo(Backbone, 'books:created', this.show);
  },

  show: function(model) {
    this.model = model;
    this.render();
    // Display the message...
  }
});

Now think for a second about what we accomplished: we know that what Flash needs in order to work is for an event to be triggered on Backbone named books:created. That's it. Now in the course of testing this component, so long as we do that and pass an instance of Book to it (or a mock), the whole of its functionality can be specced independently of any other piece of UI.

★ ★ ★

There are no hard rules on this, but once you start using global events a lot, it'll be a lot easier to track them if you name them properly.

As recommended in the Events docs, you can use colons to namespace them. It's a good strategy to namespace them in a format like subject-as-plural:action, and you can forego the pluralisation if there'll never be more than one instance of the subject as far as the client-side codebase goes. So books:created, user:sign-in, and so on.

★ ★ ★

In the previous example we used, we connected a second view-like component (Flash) to a model, but this can be used to get two views to talk to each other too.

In Lisant, the main menu on the left hand side is a separate component to the panes it expands when the corresponding button is clicked. A cut down example of how it does it looks like this:

class MainMenu extends Backbone.View
  template: JST['main_menu']
  events:
    'click .toggle-feeds-button': 'clickToggleFeeds'

  clickToggleFeeds: ->
    Backbone.trigger 'feeds:toggle'
var MainMenu = Backbone.View.extend({
  template: JST['main_menu'],
  events: {
    'click .toggle-feeds-button': 'clickToggleFeeds'
  },

  clickToggleFeeds: function() {
    Backbone.trigger('feeds:toggle');
  }
});

While the feeds component goes:

class FeedsPane extends Backbone.View
  template: JST['feeds_pane']

  initialize: ->
    @listenTo Backbone, 'feeds:toggle', @toggle

  toggle: ->
    @$el.toggleClass 'visible'
var FeedsPane = Backbone.View.extend({
  template: JST['feeds_pane'],

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

  toggle: function() {
    this.$el.toggleClass('visible');
  }
});

Referring to an instance of FeedsPane directly from within MainMenu would mean creating a hard reference to it, and that's not good architecture, as we'd now need them to be instanced in a specific order, as well as having their core functionality tied to each other. It'll be harder to test, and harder to refactor the more we add these hard references.

Granted, you'll have a hard time separating everything from everything, and it's not worth it. For instance, a list element view class is used by a UI component to render a collection. It's fine for the UI component to refer to these classes directly when creating instances to render each entry. In general (not always), so long as you apply this to interactions between distinct regions of the UI, you're doing it right.

★ ★ ★

You can use events to create a relationship between browser features and your app. That's in fact the right way to do it, as you want to keep your app as independent from it as possible.

One good example is infinite scrolling. Unlike tracking a click on an element in a view's template (for which you'd use the events object), if you're scrolling the page itself as opposed to scrolling an element, the event source will be the window object, and you need it to be visible to your app.

The best way to do it is by introducing a small module whose role is watching for the event and broadcasting it via the Backbone object. That way you can avoid tying all other views that may be interested in the event to the DOM.

As a bonus, we can also “write” to this by getting it to it watch the Backbone object for an event which can be triggered when you want to scroll the page to a specific entry, or to the top maybe.

# Scrolling module
# ================

# Object this module exports.
Scrolling = {}

# Element that we're tracking scrolling.
$el = $ window

# Trigger when this many pixels far from the bottom.
tolerance = 40

# Let this module listen to events.
_.extend Scrolling, Backbone.Events

# Tracks page scrolling and fires an event when it gets near the bottom.
Scrolling.trackScrolling = ->
  $el.on 'scroll', _.throttle (event) =>
    body = document.body
    threshold = body.scrollHeight - window.innerHeight - tolerance
    if body.scrollTop > threshold
      Backbone.trigger 'page:scrollbottom'
  , 1000

# Listens to an event which scrolls the page to an offset when triggered.
Scrolling.trackScrollTo = ->
  @listenTo Backbone, 'page:scrollto', (offset) ->
    document.body.scrollTop = offset

# Run this module by calling initialize().
Scrolling.initialize = ->
  @trackScrolling()
  @trackScrollTo()
// Scrolling module
// ================

// Object this module exports.
var Scrolling = {};

// Element that we're tracking scrolling.
var $el = $ window;

// Trigger when this many pixels far from the bottom.
var tolerance = 40

// Let this module listen to events.
_.extend(Scrolling, Backbone.Events);

// Tracks page scrolling and fires an event when it gets near the bottom.
Scrolling.trackScrolling = function() {
  $el.on('scroll', _.throttle(
    function(event) {
      var body = document.body;
      var threshold = body.scrollHeight - window.innerHeight - tolerance;
      if (body.scrollTop > threshold) {
        Backbone.trigger('page:scrollbottom');
      }
    },
    1000
    );
  );
}

// Listens to an event which scrolls the page to an offset when triggered.
Scrolling.trackScrollTo = function() {
  this.listenTo(Backbone, 'page:scrollto', function(offset) {
    document.body.scrollTop = offset;
  });
}

// Run this module by calling initialize().
Scrolling.initialize = function() {
  this.trackScrolling();
  this.trackScrollTo();
}

This recipe goes along perfectly with the link headers pagination recipe from the Models, collections & data chapter. Say we have a list of books which we want to add infinite scrolling to. The view can then listen to the event like so:

class BooksList extends Backbone.View
  # ...

  initialize: ->
    @listenTo Backbone, 'page:scrollbottom', @scrolledBottom

  scrolledbottom: ->
    if @collection.link?.next?
      @collection.fetch remove: no, url: @collection.link.next
    else
      # No further books to fetch, display some feedback.
var BooksList = Backbone.View.extend({
  // ...

  initialize: function() {
    this.listenTo(Backbone, 'page:scrollbottom', this.scrolledBottom);
  },

  scrolledbottom: function() {
    if (this.collection.link && this.collection.link.next) {
      this.collection.fetch({remove: false, url: this.collection.link.next});
    } else {
      // No further books to fetch, display some feedback.
    }
  }
});

This is terrible UX but, so the point can be demonstrated, let's say when another user leaves a comment on a book, we want to scroll the page to it, and that the method getOffsetForModel magically returns the Y axis offset for a book:

# ...

  initialize: ->
    @listenTo Backbone, 'page:scrollbottom', @scrolledBottom
    @listenTo @collection, 'comment', @commentCreated

  commentCreated: (model) ->
    offset = @getOffsetForModel model
    Backbone.trigger 'page:scrollto', offset
// ...

  initialize: function() {
    this.listenTo(Backbone, 'page:scrollbottom', this.scrolledBottom);
    this.listenTo(this.collection, 'comment', this.commentCreated);
  },

  commentCreated: function(model) {
    offset = @getOffsetForModel(model);
    Backbone.trigger('page:scrollto', offset);
  }

We now have an event which works like a command: when invoked, pass an offset to it and the page will scroll to a given offset.

Last, should we remove the Scrolling module above from the app, the functionality will be removed, but the app won't break per se.

★ ★ ★

In the spirit of henceforth adding components as features in our app, which will only add what it can do rather than become a hard dependency, another such relationship can be created between anything that sends the browser to a different URL and the router.

This pattern works if your app has only one router, as should be the case if you followed the advice given on the previous chapter.

class Router extends Backbone.Router
  # ...

  initialize: ->
    @listenTo Backbone, 'router:go', @go

  # This is just a shortcut to navigate(), and it always triggers
  # the controller, which is what you'll want most of the time.
  go: (route) ->
    @navigate route, trigger: yes
var Router = Backbone.Router.extend({
  // ...

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

  // This is just a shortcut to navigate(), and it always triggers
  // the controller, which is what you'll want most of the time.
  go: function(route) {
    this.navigate(route, { trigger: yes });
  }
});

For example, views that send the user to a different page altogether can then use this event to do so. If a router doesn't exist because none was ever initialised by the app, nothing will happen.

class BookListEntry extends Backbone.View
  template: JST['books/list_entry']
  events:
    'click .title': 'clickTitle'

  clickTitle: ->
    Backbone.trigger 'route:go', "/books/#{@model.id}"
var BookListEntry = Backbone.View.extend({
  template: JST['books/list_entry'],
  events: {
    'click .title': 'clickTitle'
  },

  clickTitle: function() {
    Backbone.trigger('route:go', "/books/" + this.model.id);
  }
});
★ ★ ★

By now you should have the main idea: events allow you to avoid having to refer to a class/module from within another class/module directly (by name). This eliminates scenarios such as modules needing to be executed in order. And as a consequence, it lets you literally assemble the UI from pieces.

There's a lot more you can do with this, though. Another idea: if you're using jQuery, globally trapping status codes such as 500 and displaying an appropriate message, or even detecting offline/online status to provide the user with feedback.

C J