Saturday, January 26, 2013

Managing Events Between Multiple Backbone Views

At first, working with Backbone's library of objects and their associated events might take a little time to completely understand. Events are built-in to every object and provide both publish and subscribe capabilities. The real strength of this architecture is visible in the Model objects where data becomes the driver for generating events. With that capability, it becomes very easy to build views that are driven from data-related changes:


var myView = Backbone.View.extend({

initialize: function () {
this.listenTo( this.model, 'change', this.doSomthing );
},

doSomething: function () {
...
}
});

...

myModel.set( 'someData', 'someValue' );

...



Any view listening to the same model will be notified of changes, etc and can take action. Now, what about non-data related events? There's already built-in support for binding to DOM events that occur inside the view's top-level DOM element:


var ItemView = Backbone.View.extend({

...

events: {
"click a": "handleItemClick"
},

...

handleItemClick: function () {
...
}

});


Additionally, views can bind and trigger their own custom events (ie onRender is a pretty common extension). And navigation can be handled using a router:


var Workspace = Backbone.Router.extend({

routes: {
"list/:type": "list",
"detail/:id": "detail",
}

});

function handleMenuSelect( type ) {

// Coordinate other views/models based on event

}

function handleItemClick( id ) {

// Coordinate other views/models based on event

}


$(function() {
var router = new Workspace();

router.on( 'route:list', handleMenuSelect );
router.on( 'route:detail', handleItemClick );

Backbone.history.start();
});


Between those different types of handlers, you can cover almost of the cases you'll encounter while building an application. But what about events that might affect other views? Certain actions that occur on the page or in application logic might need to be handled by other views other than the view originating the action (or working with that part of the DOM). One example that I recently encountered was a page where I had several menu/select type widgets which would display a popup box to make a choice. The widgets were spread across several views and at no point in time did I want more than one of the popup menus to be visible at the same time. What I needed was a way to communicate that a menu was being displayed and have all the other views ensure their menus were hidden. After trying several approaches and reviewing what others have done, I've identified three alternative ways to handle the problem. Each has their pros and cons but all of them maintain the separation of roles of each view.

Before I start outlining the alternatives, the example I'm using is a simple navigation/data table setup. The navigation could be a menu and each row in the table can be selected which could trigger some other action. I know this could just be managed using a router to implement the required handling. However, for purposes of illustration, I'm going to handle it through a series of event handlers. There are generally many ways to solve the same problem so having several of them in your bag of tricks can't hurt. I chose this example since its a common pattern and easy to visualize.

Option 1: Listen and Bubble



Every Backbone View already has the ability to trigger and bind to events. It would seem only natural to just mixin the Backbone.Events object with a mediator type object and have it listen to any events the views trigger:


var App = function () {

}

$.extend(App.prototype, Backbone.Events, {

start: function () {

...

this.nav = new NavView();
$('#nav').html( this.nav.render().$el );

this.table = new TableView({ collection: this.tableData });
$('#table').html( this.table.render().$el );

this.listenTo( this.nav, 'menu:select', this.handleMenuSelect );
this.listenTo( this.table, 'item:click', this.handleItemClick );

...
},

handleItemClick: function ( data ) {

// Coordinate other views/models based on event

}
});


In this example, App is my main mediating object that will coordinate all the views. It creates the navigation and table views and will be able to respond to the events they generate. However, the table view will be creating many item views for each row in the table. The App object won't be able to listen to any events generated by those views since it didn't create them. The only way to get the item views event to the App object will be via the table view. So as the table creates the item views, it will need to listen to the "item:click" event as well:


var TableView = Backbone.View.extend({

updateTable: function () {

var that = this,
ref = this.collection,
$table;

_.invoke(this._itemRowViews, 'remove');

$table = this.$('tbody');

this._itemRowViews = this.collection.map(
function ( obj ) {
var v = new ItemView({ model: ref.get(obj) });

$table.append(v.render().$el);
that.listenTo( v, 'item:click', that.handleItemClick );

return v;
});


},

handleItemClick: function ( data ) {

// Handle locally
...

// Bubble up the chain
this.trigger('item:click', data);
}
});

var ItemView = Backbone.View.extend({

...

events: {
"click a": "handleItemClick"
},

...

handleItemClick: function () {

// Handle locally
...

// Bubble up the chain
this.trigger('item:click', this.model);
}

});


Here, you can see that when the item view triggers the "item:click" event, the table view captures it, does any processing and then triggers the same event - effectively bubbling it up the chain in the view hierarchy. If you had a very deeply nested set of views, getting events from the lowest view up to the app would require a lot of catch and bubble work. If all the views in the chain need to handle it, then it might make sense. If the only goal is to get the event up to the App object, then it becomes fairly tedious.

Option 2: Create a Common Event Object



The next option is to flip the problem over and create a common event object that every view gets a reference to and can trigger events on so the App object (or any view, really) can respond. Now, instead of wiring up each view to listen to the next view, you just need to pass the object as an option to each view created so it can call trigger() on that object:


var App = function () {

}

$.extend(App.prototype, {

start: function () {

...

this.ev = $.extend({}, Backbone.Events);

this.nav = new NavView({ ev: this.ev });
$('#nav').html( this.nav.render().$el );

this.table = new TableView({ collection: this.tableData, ev: this.ev });
$('#table').html( this.table.render().$el );

this.ev.on( 'menu:select', this.handleMenuSelect, this );
this.ev.on( 'item:click', this.handleItemClick, this );

...
},

handleItemClick: function ( data ) {

// Coordinate other views/models based on event

}
});


var TableView = Backbone.View.extend({

ev: null,

initialize: function ( options ) {
this.ev = options.ev;
},

updateTable: function () {

var that = this,
ref = this.collection,
$table;

_.invoke(this._itemRowViews, 'remove');

$table = this.$('tbody');

this._itemRowViews = this.collection.map(
function ( obj ) {
var v = new ItemView({ model: ref.get(obj), ev: that.ev });

$table.append(v.render().$el);

return v;
});


}

});

var ItemView = Backbone.View.extend({

...

ev: null,

initialize: function ( options ) {
this.ev = options.ev;
},

events: {
"click a": "handleItemClick"
},

...

handleItemClick: function () {

// Handle locally
...

// Notify everyone listening...
this.ev.trigger('item:click', this.model);
}

});


Notice how ev is created in App and then passed to each view. App then binds to the event and only the view that triggers it needs to do anything. All the views in between just need to keep passing the object to any views they create. This is the approach that Marionette takes in the Application object available in that library. The only issue is that you must always pass the reference along to each view. However, since it is a common object, any view can listen to events triggered by another view. This is similar to how you would listen to a model's change event from any view referencing the model and respond to it. In a way, this is like a model but without any data - just the event triggers.

Option 3: Use a Global Object



If you don't want to pass an object reference around, you can always use a global object to provide a common event channel. In fact, Backbone sets one up for you in the library:



// Allow the `Backbone` object to serve as a global event bus, for folks who
// want global "pubsub" in a convenient place.
_.extend(Backbone, Events);



So, instead of ev being created and passed around, you can just reference Backbone:


var App = function () {

}

$.extend(App.prototype, {

start: function () {

...

this.nav = new NavView({ ev: this.ev });
$('#nav').html( this.nav.render().$el );

this.table = new TableView({ collection: this.tableData, ev: this.ev });
$('#table').html( this.table.render().$el );

Backbone.on( 'menu:select', this.handleMenuSelect, this );
Backbone.on( 'item:click', this.handleItemClick, this );

...
},

handleItemClick: function ( data ) {

// Coordinate other views/models based on event

}
});


var TableView = Backbone.View.extend({

updateTable: function () {

var that = this,
ref = this.collection,
$table;

_.invoke(this._itemRowViews, 'remove');

$table = this.$('tbody');

this._itemRowViews = this.collection.map(
function ( obj ) {
var v = new ItemView({ model: ref.get(obj) });

$table.append(v.render().$el);

return v;
});


}

});

var ItemView = Backbone.View.extend({

...

events: {
"click a": "handleItemClick"
},

...

handleItemClick: function () {

// Handle locally
...

// Notify everyone listening...
Backbone.trigger('item:click', this.model);
}

});


If you don't need to compartmentalize your events, the global object is very easy to use. No extra work required in the views - just bind to an event and trigger it where needed. In an app where everything on the page might need to deal with an event, this is a straight-forward way to manage it.

In the end, how you handle non-data related events that are not local to the views (and not handled by your router) is really based on the problem your trying to solve. For problems like my open menu issue, the global object made the most sense. For other problems, one of the other solutions might be a better approach. I don't think any one option is superior to the other - just certain circumstances lend themselves better to a given approach.