Backbone



Backbone

0 1


backbone-patterns-talk

A short talk I gave on Backbone patterns

On Github aron / backbone-patterns-talk

Backbone

Some ramblings…

Use of success, error handlers Only using Backbone components Putting too much responsibility on the view Passing models too far down the stack Coupling persistence to the model

1. Use of success anderror options

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

2. Only using Backbonecomponents

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

3. Putting too much responsibility on the view

What is a view?

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.

Example 1

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

Example 2

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

Example 3

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

4. Passing models too fardown the stack

The best views don’t use models

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

5. Coupling the persistence layer to the model

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 a Model?

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.

  • Having this model also know about the persistance layer breaks the single responsibility principal.
  • It also makes it very easy to tightly couple your API data structure to your app making changes hard.
  • Finally, it makes it too easy to call save in the views.

So what can we do about this?

How about an API client?

  • All the urls in one place, all the data parsing in one place.
  • Can be reused by different applications.
  • Provides a flexible layer between the application and your API
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);
          
  • It’s now the responsibility of the client to ensure the model data within the app is correct.
  • The api should be able to change (within reason)
  • And client can manipulate the data to suit without touching models.
  • The model interface can focus on representing the data.

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.)

The point at which we diverge from the talk…

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