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); } }