1 Comment

Marionette.js Behaviors, Part 1: The Basics

Part 2: Testing Behaviors is available here

Marionette is a Javascript application framework built on top of Backbone. It provides great features missing from core Backbone like collection views, subview management, and abstractions for building event driven applications.

Extracting Duplicate Code with Behaviors

Marionette.Behaviors are an abstraction for sharing UI code between different views. They are a recent addition to the Marionette toolbelt (added in version 1.7). Prior to the introduction of behaviors, code sharing between views in Marionette had to be handled at the Javascript language level with inheritance or by delegating to external modules.

Behaviors are designed to be loosely coupled and injectable. A behavior knows about the view it is injected into, but the view does not know the details of the behavior. This helps to keep behaviors abstract and reusable between views. Lets take a look at a couple simple Backbone views and how they could be refactored using Marionette behaviors.

First we will define a couple of views that need to save their data to a model when a certain UI action occurs:

var NewToBuyView = Backbone.Marionette.ItemView.extend({
	tagName: "form",
 
	className: "new-item-toBuy",
 
	template: App.template("#new-toBuy-template"),
 
	model: new ToBuyModel(),
 
	ui: {
		dataFields: ".app-toBuy-field",
		saveButton: ".app-save-new-toBuy"
	},
 
	events: {
		"click @ui.saveButton": "save"
	},
 
	modelEvents: {
		sync: "postAndClearModel"
	},
 
	save: function() {
		var self = this;
		this.ui.dataFields.each(function() {
			var $el = $(this);
			self.model.set($el.attr("name"), $el.val());
		});
 
		this.model.save();
	},
 
	postAndClearModel: function() {
		App.vent.trigger("toBuy:created", this.model.toJSON());
		this.model.clear().set(this.model.defaults);
		this.render();
	}
});
 
var ToBuyView = Backbone.Marionette.ItemView.extend({
	template: App.template("#toBuy-template"),
 
	tagName: "li",
 
	className: "item-toBuy",
 
	ui: {
		dataFields: ".app-toBuy-field",
	},
 
	events: {
		"change @ui.dataFields": "save"
	},
 
	modelEvents: {
		sync: "render"
	},
 
	save: function() {
		var self = this;
		this.ui.dataFields.each(function() {
			var $el = $(this);
			self.model.set($el.attr("name"), $el.val());
		});
 
		this.model.save();
	},
});

These two views are very similar, and their saving functionality is actually identical. This save functionality is an ideal candidate for extraction into a simple behavior. We can define such a behavior like so:

var SaveBehavior = Backbone.Marionette.Behavior.extend({
	defaults: {
		fieldSelector: ":input",
	},
 
	onSave: function() {
		var self = this;
		this.$(this.options.fieldSelector).each(function() {
			var $el = $(this);
			self.view.model.set($el.attr("name"), $el.val());
		});
 
		this.view.model.save();
	}
});

There are a couple things to take note of here.

  • The actual meat of the behavior lives in the onSave function. This function follows the Marionette convention for event handler functions, and it will automatically get fired when our view triggers a save event because behaviors automatically subscribe to all events on their associated view. This save event serves as the external interface for our behavior. One important thing to remember is that behaviors must either have an indirect external interface like an event, or wait for a UI interaction on their view in order to know when to function. This is because while views know that they have a behavior, they do not have direct access to a behavior instance.
  • The other thing to notice is the defaults configuration. Behaviors can take configuration parameters from their view, but they can also define defaults for those parameters in the same way a Backbone model can.

With the behavior defined, tying it into our views is very simple:

var NewToBuyView = Backbone.Marionette.ItemView.extend({
    tagName: "form",
 
className: "new-item-toBuy",
 
template: App.template("#new-toBuy-template"),
 
model: new ToBuyModel(),
 
ui: {
    saveButton: ".app-save-new-toBuy"
},
 
triggers: {
    "click @ui.saveButton": "save"
},
 
modelEvents: {
    sync: "postAndClearModel"
},
 
behaviors: {
    SaveBehavior: {
        behaviorClass: SaveBehavior,
        fieldSelector: ".app-toBuy-field"
    }
},
 
postAndClearModel: function() {
    App.vent.trigger("toBuy:created", this.model.toJSON());
    this.model.clear().set(this.model.defaults);
    this.render();
}
 
 
});
 
var ToBuyView = Backbone.Marionette.ItemView.extend({
    template: App.template("#toBuy-template"),
 
tagName: "li",
 
className: "item-toBuy",
 
ui: {
    dataFields: ".app-toBuy-field",
},
 
triggers: {
    "change @ui.dataFields": "save"
},
 
modelEvents: {
    sync: "render"
},
 
behaviors: {
    SaveBehavior: {
        behaviorClass: SaveBehavior,
        fieldSelector: ".app-toBuy-field"
    }
}
 
});

The changes here are pretty straightforward. We eliminated the save function in each view and replaced it with a behavior configuration. We also changed our UI interactions from explicit callbacks of a save method on the view to triggers of a save event on the view.

And just like that, our two views have the duplicate behavior extracted out and are much cleaner as a result. This is a pretty trivial example of the type of refactor you can do with a behavior. We have used behaviors in real world projects for such things as sorting table views, making background ajax requests, and extracting complex multi-part ui interactions.

Behavior Pro-Tips

1. Use events rather than UI interactions to trigger behaviors.

Behaviors have two main ways to interact with your view and know when to perform their action. They can listen for events on the view itself or a global event bus, or they can observe the UI of your view directly and listen for associated DOM events. Of these two options, we vastly prefer the first and use it whenever possible.

Listening to view events serves to further decouple your behaviors from your views and tends to make them more easily reusable. With this pattern, views can decide which UI interactions they want to trigger a behavior, and then they can explicitly control the running of said behavior by firing an event. This way the behavior has to know little or nothing about your views UI and can just contain business logic.

A great example of where this work out well is the SaveBehavior we defined above. In one of the views, we want to perform our save action when we press a “save” button on our UI, but in the other view, we want to perform the save action every time the value of one of our fields changes. This duality would be difficult to encode into the behavior itself, and we would likely need to pass in configuration to control it. A better solution in this case is to use the save event, and let each view decide when that event should get fired.

2. Use view lifecycle callbacks.

Building on the idea above of using view events to control our behaviors, it is good to know that behaviors subscribe to all view events. This includes the built in lifecycle events like show, render, and close which can be handled by defining an appropriate onMethod function. These give lots of opportunities to inject business logic into a view’s normal lifecycle.

3. Use behavior injection to configure views.

While behaviors come with a lookup module that will try and find an appropriate behavior class based on name for a particular view, I find that I never actually use that feature in favor of explicitly declaring my Behavior class every time.

One advantage to this is that you can selectively inject different behaviors into views based on your current context. One situation where I often use this if I need to selectively turn off a behavior in a view. For example, if I have a table view in multiple spots of my app and in some of those spots it should be sortable, but in other spots it should not. In that case I would abstract the sorting into its own behavior, and then when initializing the view we can either set our SortBehavior to be our actual implementation, or the base Marionette.Behavior, which does nothing if we want to turn sorting off.

Var TableView = Marionette.CollectionView.Extend({
  ...
  behaviors: {
    SortBehavior: {
      behaviorClass: this.options.SortBehavior // Can be an actual SortBehavior, or a base Marionette.Behavior depending on whether we want sorting turned on or off.
    }
  }
});

4. Use composition to create more complicated behaviors.

As of the latest version of Marionette, behaviors can also be composed with each other to create more complex behaviors. This can work very well for UI workflow behaviors, or any case where a behavior has more than one useful function. A concrete example could be an AutoSaveBehavior that composes with our earlier SaveBehavior to save a views attributes every time they change:

var AutoSaveBehavior = Marionette.Behavior.Extend({
  defaults: {
    fieldSelector: ":input",
  },
 
  ui: function() {
    return {
      fields: this.options.fieldSelector
    }
  },
 
  triggers: {
    "change @ui.fields": "save"
  },
 
  behaviors: function() {
    return {
      SaveBehavior: {
        behaviorClass: SaveBehavior,
        fieldSelector: this.options.fieldSelector
      }
    }
  }
});

Now a view that wants to autosave fields just has to include this one behavior.

Conclusion

Marionette is a solid choice for a Javascript application framework, and abstractions like behaviors continue to make it very appealing. We hope we were able to convince you of the positive impact of using behaviors in your Marionette projects! Some sample code from this post is available on github