Saturday, October 29, 2011

Simple Custom Events in Backbone.js

‹prev | My Chain | next›

After refactoring the views in my appointment calendar Backbone.js application, I am ready to move onto to other topics. Except that I forgot a TODO that I left myself in the month View:
    var CalendarMonth = Backbone.View.extend({
      tagName: 'table',
      initialize: function(options) {
        this.date = options.date;
      },
      render: function() {
        // TODO:
        // $('span.year-and-month', 'h1').html(' (' + this.date + ')');

        // ..
      }
    });
Ah. My old template-based view was doing so much that I had not felt bad about adding a side-effect in there as well. This particular side-effect adds the date to the page's <h1> tag.

I want a Backbone view that is dedicated to updating the page's title—a title view. If the page title has a year-and-month span in it:
<h1>
  Funky Calendar
  <span class="year-and-month"></span>
</h1>
Then I want my title view to populate said <span> tag whenever the month changes.

The appointment collection only holds appointments from the current month. It also exposes a getDate() getter for the current month. I should be able to use this to update the title:
    var TitleView = Backbone.View.extend({
      tagName: 'span',
      initialize: function(options) {
        $('span.year-and-month', 'h1').
          replaceWith(this.el);
      },
      render: function() {
        $(this.el).html(' (' + this.collection.getDate() + ') ');
      }
    });
My main Application view is injected with the month appointments collection:
appointments = new Collections.Appointments({date: year_and_month}),
application = new Views.Application({
  collection: appointments,
  el: root_el
});
It can then inject the same collection into the title view when it creates it:
    var Application = Backbone.View.extend({
      initialize: function(options) {
        // ...
        this.initialize_title();
      },
      // ...
      initialize_title: function() {
        new TitleView({collection: this.collection});
      },
      // ...
    });
But how to get that to change when the user navigates between months? When it was a side-effect of the calendar render(), that was easy. But now?

The answer is to use events—this is Backbone after all. In the appointment collection, I trigger a custom event, calendar:change:date, after successfully fetching a month's appointments:
    var Appointments = Backbone.Collection.extend({
      model: Models.Appointment,
      // ...
      fetch: function(options) {
        options || (options = {});

        var data = (options.data || {});
        options.data = {date: this.date};

        var collection = this;
        var success = options.success;
        options.success = function (resp, status, xhr) {
          collection.trigger('calendar:change:date');
          if (success) success(collection, resp);
        };

        return Backbone.Collection.prototype.fetch.call(this, options);
      },
      // ...
    });
By triggering the event in the success callback of my fetch() class, I ensure that the date in the calendar's title will only change if the next month is successfully obtained.

The dance of saving existing success callbacks might be unnecessary future-proofing on my part. I have gotten in the habit of doing that, especially in my models where is makes more sense. I am not currently calling this collection's fetch() with any options, so ensuring that a success callback is retained is definitely overkill. If I ever did want to pass in a success callback, I would likely check to ensure that fetch() was not overridden and, if it was, that it supported the success callback. Still, this feels like a good habit to have.

The event itself, calendar:change:date is chosen to avoid conflicting with "real" events. If the model's date is updated in the application, the model automatically emits a change:date event. To prevent confusion with that "real" event, I opt for a clearer namespaced event. There is no need to update the title of the page if the user updates an appointment!

With my collection successfully generating the event, I can now bind to that event in my view:
    var TitleView = Backbone.View.extend({
      tagName: 'span',
      initialize: function(options) {
        options.collection.bind('calendar:change:date', this.render, this);

        $('span.year-and-month', 'h1').
          replaceWith(this.el);
      },
      render: function() {
        $(this.el).html(' (' + this.collection.getDate() + ') ');
      }
    });
With that, I have dates back in the title of funky calendar:
For good measure, I also tie my calendar navigation view to listen for this event (so that next and previous links can use the proper months).

I really felt bad about updating the title as a side-effect of rendering the new calendar month. This is a bit more code, but it feels much cleaner—mainly because it only has a single responsibility. In addition to clearer intent (what could be clearer than "TitleView"?), I am secure knowing that, should I need to make this more sophisticated in the future, I know where changes need to go.


Day #189

No comments:

Post a Comment