On Github aron / backbone-patterns-talk
Backbone.Model has returned promise objectsfor a while now, lets use them.
// Old style options.
model.fetch({
success: function (data) {
// Handle success.
},
error: function () {
// Handle error.
}
});
// With promises.
model.fetch().then(function (data) {
// Handle success.
}, function () {
// Handle error.
});
// Wrapping errors (Antipattern)
fetch: function (options) {
var oldSuccess = options && options.success;
options.success = function () {
oldSuccess && oldSuccess();
}
return Parent.prototype.fetch.call(this, options);
}
// Hooking into errors (Suggested pattern)
fetch: function () {
var request = Parent.prototype.fetch.apply(this, arguments);
request.then(onSuccess, onError);
return request;
}
// We can even seperate the request actions from unrelated code.
fetch: function () {
return this.model.fetch();
},
refresh: function () {
this.showSpinner();
this.reload().then(hideSpinner).then(render);
}
Not everything has to be an extensionof a Backbone object.
// Here's a controller.
// It handles creating a view or two and fetching some models.
// Maybe it persists them, or just triggers events for other
// parts of the app to take care of.
function MentionController() {
this.model = new Mention();
this.view = new MentionView({model: this.model});
this.view.on("click:create-button", function () {
// Handles loading a modal and saving a mention.
new CreateMentionController();
});
}
What is it’s responsibility within the codebase?
We use a view to render a dom element andlisten for interactions.
The view then reports these interactions back to a controller/parent view
In an ideal world the view should have no knowledge of the application outside of it's root element and any child views.
The update button has been clicked;how do we update the model?
// WidgetForm.js (Antipattern)
onSubmit: function (event) {
this.model.save(formData(), {
success: function () {
// Show the widget.
},
error: function () {
// Show an error notification.
}
});
}
// WidgetForm.js (Suggested Pattern)
function triggerSubmit(view, data) {
view.trigger('event', data);
}
onSubmit: function (event) {
triggerSubmit(data, getFormData(event.target));
},
render: function () {
this.el.innerHTML = renderTemplate(this.model.attributes);
return this.el;
}
// WidgetControllerForm.js (Usage)
var widget = new Widget();
var widgetForm = new WidgetForm({model: widget});
someContainer.appendChild(widgetForm.render());
widgetForm.on('submit', function (data) {
widget.save(data).then(showWidget, function (xhr) {
var errors = $.parseJSON(xhr.responseText).errors);
// show a notification.
});
});
// Test.js
var widget = new Widget();
var widgetForm = new WidgetForm({model: widget});
var spy = sinon.spy();
widgetForm.on('submit', spy);
widgetForm.render();
widgetForm.$('input').val('test')
widgetForm.$('button').click()
expect(spy.calledWith({value: 'test'})).toBe(true);
An error has occured, how do we notify the user?
// AutoCompleteView.js (Antipattern)
onKeypress: function (event) {
event.preventDefault();
suggestGlobal.get(event.target.value, {
success: updateView,
error: function (err) {
jQuery.notify('<p>Unable to fetch suggestions</p>');
}
});
}
// AutoCompleteView.js (Suggested Pattern)
suggest: function (partial) {
suggestGlobal.get(event.target.value, {
success: updateView,
error: function (err) {
NotificationHub.trigger('error', 'Unable to fetch suggestions', err);
}
});
},
onKeypress: function (event) {
suggest(event.target.value);
}
// NotificationController.js (Usage)
var $notification = jQuery('.page-notifications');
NotificationHub.on('error', function (message, error) {
jQuery.notify(message, {type: 'error'});
});
// Test.js
setupSuggestGlobal(); // Cause the fetch to error.
var stub = sinon.stub(NotificationHub, 'trigger');
var view = new AutoCompleteView();
view.$('input').val('part').keypress();
expect(stub.calledWith('error', 'Could not fetch suggestions')).toBe(true);
// Controller Test.js
var spy = sinon.stub(jQuery, 'notify');
NotificationHub.trigger('error', 'Could not fetch suggestions');
expect(spy.calledWith('Could not fetch suggestions')).toBe(true);
The new widget button has been clicked,let’s show a form!
// CreateWidgetButton.js (Antipattern)
onNewClick: function (event) {
event.preventDefault();
WidgetModalForm.show(this.model);
}
// CreateWidgetButton.js (Suggested Pattern)
function triggerClickEvent(view) {
view.trigger('submit');
}
onNewClick: function (event) {
event.preventDefault();
triggerClickEvent(this);
}
// WidgetController.js (Usage)
var button = new CreateFormButton();
document.body.appendChild(button);
button.on('submit', function () {
var form = new CreateWidgetForm();
form.on('submit', createModel);
appModal.appendChild(form.el);
appModal.show();
});
// Test.js
var button = new CreateFormButton();
var spy = sinon.spy();
button.on('submit', spy);
button.$('form').submit();
expect(spy.wasCalled).toBe(true);
If you have a small component that doesn’t need to listen to changes to a model, pass only the data that’s required to render it.
// Antipattern, this view is dependant on a model for one value.
Backbone.View.extend({
events: {
'input[type=range] change': function (event) {
this.trigger('save', this.model);
}
},
render: function () {
var html = '<input type="range" value="' + this.model('value') + '">';
this.$el.html(html);
}
});
// This view now neeeds a Widget to work.
// Tests need to create an instance of a Widget with fixture data.
var widget = new Widget({name: 'foo', percentage: 10, ... });
var view = new Slider({model: widget});
model.render();
model.$('input').change();
expect(stub.calledWith({value: 10}).toBe(true);
Backbone.View.extend({
events: {
'input[type=range] change': function (event) {
this.trigger('change', event.target.value);
}
},
constructor: function Slider(options) {
this.value = options.value || 0;
},
render: function () {
var html = '<input type="range" value="' + _.escape(this.value) + '" />'
this.$el.html(html);
}
});
// Move model handling responsibility into another object.
var model = new Widget({percentage: 10});
var sliderView = new SliderView({value: model.get('percentage')});
sliderView.on('change', updateModel);
// View tests become cleaner with and have zero dependancies.
var sliderView = new SliderView({value: 10});
expect(sliderView.$('input').val()).toBe(10);
// Interactions can be easily validated.
sliderView.on('change', spy);
sliderView.$('input').val(2).change();
expect(spy.calledWith(2)).toBe(true);
Okay, this isn’t an anti-pattern, it’s a problem I have with Backbone. It doesn’t make the framework bad, but it’s the biggest problem I’ve seen people run into when it growing a codebase built on Backbone.
What is it’s responsibility?
A model represents a piece of application state. It’s job is to notify views/controllers when this state changes.
How about an API client?
var client = new APIClient(endpoint);
client.getMention(id); // Return a mention.
client.getMe(); // Return a user.
client.updateUser(id, data); // Update a user.
// The client can then be used by controllers
// to persist model data.
client.getMention(id).then(function (data) {
return new Mention(data);
});
// The Backbone API can be maintained by passing in the store.
// This is useful for allowing the model to be lazy loaded.
new Mention(data, {client: client}).fetch().then(blah, bleh);
This allows the application do determine owndomain language.
(The closer this is to the API the better, but this gives flexibility for the two to diverge without huge refactorings.)
// Need caching? Want local storage?
// Lets wrap the client in a store/cache layer.
var store = new Store(client);
// No id just get an empty model.
var mention = store.get('mention');
// With an id get a model with a loading state.
var mention = store.get('mention', id);
// maybeFetch works like fetch but will return
// a local version of the data if available.
mention.maybeFetch().then(function () {
mention.get('id') //=> 1
});
// Views can be passed models that may/may not be loaded
// they can handle both possibilities.
new mentionView = new MentionView({model: mention});
// MentionView
render: function () {
if (model.isNew()) {
this.renderLoading;
} else {
model.maybeFetch().then(this.renderLoaded);
}
}