English |  Español |  Français |  Italiano |  Português |  Русский |  Shqip

Developing a Backbone.js Edge

Chapter 6: Collections

More often than not your client-side application will be dealing with sets of data rather than single items of data, for example: users, pages, products, documents, etc. We have looked at how Backbone represents individual items of data as Models. In this chapter we will look at how to work with multiple items of data through Backbone Collections.

From the Backbone Documentation: "Collections are ordered sets of models." Using Collections you can easily sort and manipulate your data without fetching it afresh each time from the server. Take for example an application for managing the users of a blogging system. You may want to be able to see which users have authored the most articles, which users have written the most comments, or who the newest users are. In a traditional server-based application, each time you wanted a different view of the data you would have to load a new page from the server. This is not very fast, and not really necessary as the data hasn't changed, just the order in which it is displayed. If the application were built with Backbone, then there would be a collection of user models. Sorting the users would be a simple case of calling:

users.sortBy(function(user){ return user.get('number_of_posts'); });

This would sort the models by the "number_of_posts" field. You could easily resort the data by another field:

users.sortBy(function(user){ return user.get('date_joined'); });

Once the data has been sorted, it can be re-rendered on the page.  

Backbone Collections inherit many methods from Underscore (see Chapter 1: The Setup for more information), which make it easy to sort, group, and filter your Models.

Defining A Collection

Collection Classes are defined in a simliar way to Models. They can be extended either from the base Backbone.Collection or from another Collection that you've defined. Here is a simple Collection Class for Issues:

// collections/issue.js
(function (window) {
'use strict';
var app = window.app;
var Backbone = window.Backbone;
var Issue = app.Issue = app.Issue || {};
Issue.Collection = Backbone.Collection.extend({
model: Issue.Model,
comparator: function (issue) { return -1 *
Date.parse(issue.get('created_at')); },
url: '/issues'
});
})(this);

Let's look at the three properties that we've defined.

  • Comparator - this is a function that is invoked every time a model is added to the collection. It keeps the collection ordered correctly. In this collection, issues are sorted by the date they've been created--newest first. If we had three issues, all which were created on 2013-01-01 and I added an issue that was created on 2013-01-10, the new issue would be added to the front of the collection rather than the end.
  • Model - while collections can be polymorphic and contain different types of models, standard practice is to have different collection classes for different model classes. By referencing the model class in the collection class, we have an easy way to add new models to the collection from raw data sources (either a REST api or a html form).
  • URL - Backbone works very well with REST APIs. The URL property allows you to define the url to "GET" the remote data from the server. It can also be used to define the urls to which individual models send "GET," "POST," "PATCH," "PUT" and "DELETE" commands to the server.

For example, you could instantiate a new collection from the above class like this:

collection = new Issue.Collection();

To get data from the server you could call collection.fetch();. This would issue an AJAX GET request to /issues. The expected response would be an array of data items. When these items are returned from the server they would be converted into Issue.Models and added to your collection (ordered by date created). If you made a change to a model in the collection and that model had an ID of 10, then the PUT request to the server would be sent to /issues/10.

Backbone is quite flexible when it comes to working with legacy APIs or services that are outside your control. You can define a parse method to extract the actual array of data from your web service's response. For example if you received responses like this:

{"message": "Success", "date": "2013-01-10", "items": [ 
{"id": 1, "title": "First Issue"},
{"id": 2, "title": "Second Issue"}
]}

You could write a parse method as follows:

Issue.Collection = Backbone.Collection.extend({ 
model: Issue.Model,
url: '/issues',
parse: function(response){ return response.items; }
});

This method ensures that the collection will receive the array of data rather than response metadata.

You can define any custom methods on your collections in the same manner as you can with models. After defining and creating your collections, you need to be able to add and remove models from it, whether in response to an internal event, user input, or an update from the server.

Adding and Removing Models

Let's take a look at how you can add or remove models from collections. Backbone provides a variety of flexible methods to achieve this. It's important to remember that a model may be in several different collections at the same time. Collections usually contain a filtered subset of the data in your database, so adding and removing models from a collection is not equivalent to adding and removing data from your database. This is an important distinction between models and collections. Models usually have a direct mapping to the data in your database. You would normally persist changes in your model data to the server.

Adding Models

You can add existing Backbone Models or raw objects to Collections. Raw objects will first be converted to models using the model constructor defined in the collection class (or the standard Backbone.Model if no model constructor is defined). There are three basic methods for adding models:

  • add: adds a model or an array of models to the collection.
  • push: adds a model to the end of the collection.
  • unshift: adds a model to the beginning of the collection.

These three methods operate completely independent of your server; however, often you want to add a model to a collection and at the same time save that model to your server. Perhaps you have a commenting system and someone is adding a comment. You want the new comment to immmediatly be in your comments collection and then asynchronously saved to the server. To do this you would use collection.create. If you are syncing to a server with a REST API, then this method works as follows:

  • Creates a model with the raw data - (triggers "change" events on the model)
  • Add this model to the collection - (triggers an "add" event on the collection)
  • Issues a POST request to the server to add the model data to your database (triggers a "request" event on the model)
  • If the request is successful then trigger a "sync" event on the model
  • If the request is successful then update the model with the response from the server (normally the server will return a unique id for the row / document) - (triggers "change" event on the model)
  • If the request is unsuccesful, then an "error" event is triggered

The above flow is an example of how Backbone helps you create ultra fast client-side applications. We can assume that most of your application's interactions with the server will be succesful. Therefore, there is no need to make the user wait while persisting data to the server. If there is an error then this can be handled by listening for the error events and notifying the user accordingly. If you would prefer to wait for the server's response before adding the model to the collection, then you can pass in {wait:true} as an option.

Often you may have existing data in your collection, but you want to update it with the latest data from the server. Backbone provides two methods for this:

  • reset - this replaces all of the models in the collection with the fresh models.
  • update - this intelligently updates the collection based on the fresh data. New models are added, existing models are changed and any models that aren't in the fresh data are removed.

Both reset and update can be called internally by the collections sync method. The default behaviour is to use reset, so each time you call fetch all models in your collection will be replaced. If {update:true} is passed as an option to the fetch method, the update method is used.

As an example to keep the data in your collection up to date, you could set up a setInterval to fetch the data every one minute.

setInterval(function(){ issues.fetch({update:true}); }, 1 * 60 * 1000);

This code will run the fetch method every minute to get data from your server. We've passed update:true as an option, so every minute when the data is retrieved from the server it will be passed to the collection's update method. This method iterates through the data items and tries to match each item with existing models in the collection:

  • If a match is found, then the model is updated with the fresh data from the server. "Change" events will be triggered on the model for any attributes that have changed.
  • If no match is found, then the data is added as a new model and an "add" event is triggered.
  • If there are models in the collection that haven't been matched to the incoming data they will be removed triggering a "remove" event.

This allows you to efficiently update your views with only data that has been changed, rather than re-rendering your views every time data is fetched from the server.

In the Hubbub example appplication, we are working with the public GitHub API, which limits requests to sixty per hour. Because of this we haven't implemented any automatic fetching. Rather, fresh data is only fetched from the server when the user clicks the refresh icon for a repo.

Now let's take a look at the various remove methods that Backbone provides.

Remove Methods

These methods remove models from the collection (but don't destroy the model, or the model data). Again, remember that collections aren't an exact mapping to tables in your database. You can have multiple collections of the same data ordered and filtered in different ways. There are three removal methods:

  • remove: removes the passed in model from the collection.
  • pop: removes the last model in the collection and returns it.
  • shift: removes the first model in the collection and returns it.

All of the above methods will trigger a "remove" event on the collection. In some ways you can view collections as arrays of models. All the usual array operators are available: push, pop, shift, unshift, length, as well as many utility functions for filtering and manipulating the collection's models. We've looked at how to get models in and out of collections, now lets take a look at sorting and filtering models once they are within a collection.

Sorting and Filtering

As we've already mentioned, Backbone Collections inherit a variery of useful filtering and sorting methods from Underscore.  We're going to illustrate the power and simplicity of these methods through a series of tests. As explained in the testing chapter, tests are an important part of keeping your application bug-free and maintainable. Collections are quite easy to test because you are dealing with data rather than user interaction.

In our Hubbub application we need to be able to filter issues by their category and by which repo they belong to. Lets look at some examples of how to do this with some test data. 

// test/generic.js
var testData = [
{"id": 10338616, "title": "Comparator and fat arrow", "number": 2195,
"repoId": 952189, "category": "doing", "comments": 5, "created_at":
"2013-01-26T14:35:16Z"},
{"id": 10341232, "title": "trigger calls unbinded event handlers",
"number": 2198,
"repoId": 952189, "category": "todo", "comments": 21, "created_at":
"2013-01-26T18:26:53Z"},
{"id": 10339785, "title": "All Code refactoring", "number": 2196,
"repoId": 952189, "category": "doing", "comments": 1, "created_at":
"2013-01-26T16:29:40Z"},
{"id": 10172489, "title": "Reverting changes from #2003 and 1f3f45252f",
"number": 2173, "repoId": 952189, "category": "done", "comments": 3,
"created_at":
"2013-01-21T21:36:58Z"}
];
var issues = new app.Issue.Collection(testData);

The above code creates a new Issues Collection with the four sample issues from the testData array. The following are all available in the test suite: http://backstopmedia.github.com/hubbub/test/index.html

To create the tests as you read along, create a file named "generic.js" in the test directory (these are generic Backbone tests rather than Hubub specific ones). Set up the test file with the following code:

window.jQuery(function () {

var module = window.module;
var _ = window._;
var app = window.app;
var ok = window.ok;
var test = window.test;
var equal = window.equal;
var deepEqual = window.deepEqual;
module("generic");

Then add the test data, and start adding the tests below.

Testing sortBy and comparator

// test/generic.js
test('sort by title', function () {
var issues = new app.Issue.Collection(testData);
equal(issues.at(0).get("title"), "trigger calls unbinded
event handlers");
// This is the first item before sorting
var sorted = issues.sortBy("title"); equal(sorted[0].get("title"),
"All Code refactoring");
// After sorting this item is first
});

This test creates a new Issue Collection with the test data. We first ensure that the models are correctly ordered by "created_at" when they are added. If no comparator had been set then the first models would be the first item in the array.

When a collection is instantiated any models added to it are kept in property named models. While it is possible to access this array of models directly its usually preferable to use the at method. So in the above test issues.at(0) is equivalent to issues.models[0].

sortBy is a method inherited from underscore.js. It accepts either an iterator function or a property key. For this simple sort, we can simply pass in "title", and a new array of models will be returned ordered by each model's title. Its important to note that this method and others like it don't affect the collection itself, they rather return a new array of filtered or sorted models.

Testing sortBy with an iterator

Here is another test with an iterator function rather than a property key. This time the models are ordered by the length of their titles.

// test/generic.js
test('sort by longest title', function () {
var issues = new app.Issue.Collection(testData);
var sorted = issues.sortBy(function(issue) { return -1 *
issue.get("title").length; });
equal(sorted[0].get("title"), "Reverting changes from #2003
and 1f3f45252f");
// After sorting this item is first
});

As you can see it is fairly easy to sort the models in your collection. You could also create more complex sortBy interators to sort by two or three properties. 

Filtering Collections

As well as sorting, Backbone Collections have several useful methods for filtering models. In our Hubbub application we need a collection that contains issue models that have the category: "done." For simple filters like this, we can use the where method. This accepts a hash of keys and values to test models against, e.g. issues.where({category:"done"}); This would return all models with a property of category that is equal to "done."

For more complex queries we can use the following underscore methods.

  • find: returns the first model that passes a test.
  • filter: returns all models that pass a test.
  • reject: returns all models that fail a test.

The above three methods all take an iterator function, for example:

// test/generic.js
test('filter by title contains "event"', function () {
var issues = new app.Issue.Collection(testData);
var filtered = issues.filter(function(issue) {
return issue.get("title").indexOf("event") !== -1; });
equal(filtered.length, 1);
// Only one model found
equal(filtered[0].id, 10341232);
// Confirm correct model is returned
});

The function that is passed into the filter method is called with each model in the collection and should return the result of a test. If we had used reject rather than filter then the other three models would have been returned instead. You can use these functions to quickly and powerfully filter your collections. For example as the user is typing into a search box, you could filter a collection according to the text they are entering and update the results view instantly.

If you are doing a lot of complex filtering on your collections then there are some Backbone plugins that abstract a lot of the logic away for you and provide a query API similar to MongoDB: https://github.com/bevry/query-engine https://github.com/davidgtonge/backbone_query.

Grouping Collections

Sometimes you need to group the models in a collection according to a model property. There are two methods you can use for this.

  • groupBy - Groups the models into different arrays based on the return value of an iterator.
  • countBy - The same as groupBy, but returns the number of models rather than an array of models.

countBy is similar to groupBy but deals with a common use case, simply adding up how many models there are in each group.  These methods could be used to find out how many issues there are in each of the categories:

// test/generic.js
test('collection: groupBy & countBy', function () {
var issues = new app.Issue.Collection(testData);
var iterator = function(issue) { return issue.get("category"); };
var grouped = issues.groupBy(iterator);
var counts = issues.countBy(iterator);
deepEqual({ done:1, doing:2, todo:1 }, counts);
equal(counts.doing, grouped.doing.length);
});

Along with filtering, sorting, and grouping our collections, we need to be able to access the data in our collections, either to display to the user, or to use in our application logic. Let's take a look at some of the Underscore methods that can help with this.

Retrieving Data From Collections

Sometimes you need to retrieve specific values from each Model in a Collection, at other times you may want to check if a Collection meets certain criteria and perform an action accordingly. The next three methods don't return any specific data, rather they return true or false depending on if the collection passes a test.

Methods Returning a Boolean

These methods return a boolean:

  • every or all: returns true if all the models pass a test
  • some or any: returns true if any of the models pass a test
  • include or contains: returns true if the collection contains a passed in model

Again to illustrate these methods, let's run some tests on our sample data. In the following code we're going to check if our collection contains models with various category values.

// test/generic.js
test('collection: some', function () {
var issues = new app.Issue.Collection(testData);
var hasToDoIssues = issues.some(function(issue) {
return issue.get("category") === "todo"; });
var hasRejectedIssues = issues.some(function(issue) {
return issue.get("category") === "rejected"; });
equal(hasToDoIssues, true);
// This variable should be true as there are issues with a
todo category
equal(hasRejectedIssues, false);
// This variable should be false as there are no issues with a
rejected category
});

You can see that "hasToDoIssues" is true because there is at least one issue with the category of "todo." "hasRejectedIssues" on the other hand is false, as our collection doesn't contain any models with the category of "rejected".

Now let's look at getting data from each model in our collection.

Methods Returning an Array

The next two methods iterate through all the models in a collection and return the requested properties as an array.

  • map - returns a new array of the results of passing each model through an iterator
  • pluck - returns an array of "plucked" values from a specific model property

Pluck is a simpler version of map and often provides a succinct way of getting the data you require. Here is a test demonstrating the use of map and pluck to generate an array of issue numbers.

// test/generic.js
test('map & pluck', function () {
var issues = new app.Issue.Collection(testData);
var mapped = issues.map(function(issue) { return issue.get("number"); });
var plucked = issues.pluck("number");
equal(plucked.toString(), mapped.toString());
// Mapped and plucked arrays should be the same
equal(plucked.length, 4); // Should have 4 values
equal(plucked[0], issues.at(0).get("number"));
// First value should be equal to the number property of the
first model
});

You can see that we used both methods to achieve the same goal. "Map" is more powerful however, you could perform various calculations in the iterator function and return the result of those, rather than a staight property value.

The next set of methods return either a single value or a single model from a collection.

Methods Returning a single value

These methods are especially useful if your models contain properties with numerical values:

  • reduce - iterates through all the models to return a single value.
  • max - returns the model with the highest value returned from the passed in function.
  • min - returns the model with the lowest value returned from the passed in function.

You could use reduce to add up the total number of comments of all the issues in a collection:

// test/generic.js
test('collection: reduce', function () {
var issues = new app.Issue.Collection(testData);
var reduceIterator = function(sum, issue) { return sum +
issue.get("comments"); }
var numberOfComments = issues.reduce(reduceIterator, 0);
// We pass an iterator function and the starting "memo" value
equal(numberOfComments, 30);
// The total number of comments from all the issues.
});

This method works by iterating through all of the models in the collection and adding the "comments" value to the result of the last iteration. We pass in 0 to the method, so that the first iteration has something to add to. Each iteration should return the adjusted value ready for the next iteration.

max and min could be used to find the issue with the most or least comments:

// test/generic.js
test('collection: max & min', function () {
var issues = new app.Issue.Collection(testData);
var iterator = function(issue) { return issue.get("comments"); };
var mostCommentsIssue = issues.max(iterator);
// We can use the same iterator for both the min and max methods
var leastCommentsIssue = issues.min(iterator);
equal(mostCommentsIssue.id, 10341232);
equal(leastCommentsIssue.id, 10339785);
});

Methods operating on all of the models in a collection

Sometimes you don't need to extract any data from your collection, rather you want to operate on all of the models within that collection. Backbone provides two methods for this:

  • each - this is the most basic of the underscore iterators, it simply calls the passed in function on each model in the collection.
  • invoke - this method calls the passed in method name on each model in the collection.

The invoke method can help keep your code terse where you need to call the same function on all models. For example this code: issues.invoke('fetch'); will call fetch on all the models in the collection. This is clearer to read and less verbose than: issues.each(function(model) { model.fetch(); });.

Learning to use the underscore methods with your collections can hopefully keep your code clean and stable. Another important principle in keeping your application lean and maintainable is the correct usage of events. As we've discovered earlier, Backbone provides a powerful events system, lets take a look at some of the Collection specific events that are built into Backbone.

Events

Events are an important part of Backbone Collections. Each collection has access to the same event methods as Models, Views and Routers. For an explanation of the various event methods (on, off, etc.) please refer to Chapter 3: Backbone Events.

Events are a flexible way of strucuturing your app and help a lot with keeping your code modular. For example 1 or 20 views could listen to events on a collection, without you needing to change any of the code for that collection.

You can trigger any custom events on your collections, but the following events are built into Backbone:

  • add: when a model is added to the collection.
  • remove: when a model is removed from the collection.
  • reset: when the collections models have been replaced.
  • sort: when the collection has been re-sorted.
  • destroy: when a model in the collection is destroyed.
  • request: when the collection has started a request to the server.
  • sync: when a collection has been successfully synced with the server.

Any event that is triggered on a model in the collection is also triggered on the collection itself. For example if the "category" attribute of a model is changed then "change:category" would be fired on that model and on the collection that it belongs to.

In the Hubbub application, we use many of these events to ensure that collections stay in sync with views and with other collections. To illustrate how events work lets look at the setFilter method in the issue collection class.

We use this method to create filtered collections for the different issue categories. The idea is that there is a "master" collection that contains all of the issues. There are then separate "filtered" collections that contain only a subset of those issues based on category.

If the issue's categories weren't going to change, we could simply use the where method described above to filter the issues and add them to the appropriate collection. However we want the filtered collections to automatically stay updated when a model's category changes and when models are added or removed from the master collection:

// collections/issue.js
setFilter: function(parent, testKey, testValue) {
var self = this;
var onAdd = function(model) {
// only add to this collection if the model passes the filter
if (model.get(testKey) === testValue) {
self.add(model);
}
};
// If there are any existing models in the collection run them
// through the add parent.each(onAdd);
this.listenTo(parent, {
// When a model is added to the parent, add it to this
// collection if it matches the test key / value
add: onAdd,
// When a model is removed from the parent, remove it from this
// collection.
// This method is a "no op" if the collection doesn't contain the model.
remove: self.remove
});
// Listen to change events on the models
this.listenTo(parent, 'change:' + testKey, function(model, value) {
if (value === testValue) {
// If the new value matches the test value then add the model
self.add(model);
} else {
// If the value doesn't match the test value then remove the model
self.remove(model);
}
});
}

The method accepts a "master" (parent) collection and a key and value to test models against. Let's test the code to ensure that it works as planned:

// test/hubbub.js
test('collection: setFilter method', function () {
var issues = new app.Issue.Collection(testData);
var done = new app.Issue.Collection();
done.setFilter(issues, "category", "done");
equal(done.length, 1); 

// Contains just the one model with a done category
issues.get(10172489).set("category","doing");
equal(done.length, 0);  // Contains no models as the one model with a 'done' key had its category changed to doing
issues.invoke("set", "category", "done");
equal(done.length, 4);  // All 4 models are now, in the done collection });

You can see that issue models are automatically added to and removed from the "done" collection as the models' category attribute is changed. 

Once collections and views are set up to listen to the correct events, then simply changing an attribute on a model will cause everything to be updated correctly. 

Class Methods

Like Models, Collections allow you to define class-level properties/methods.  Let's start by defining a Collection for our list of repositories:

// collections/repo.js
(function (window) {
'use strict';
var app = window.app;
  var Backbone = window.Backbone;
var Repo = app.Repo = app.Repo || {};
Repo.Collection = Backbone.Collection.extend({
    model: Repo.Model,
// sort by owner login name, then repo name
comparator: 'full_name'
});
})(this);

In Hubbub, we want to keep track of the list of repos the user has added to their board.  Therefore, let's define a Repo Collection as a property on a Board:

// models/board.js
initialize: function () {
this.repos = new app.Repo.Collection();
// ...
}

In this case, we are instantiating a Collection the normal way: with the new keyword.  In Hubbub, we want our users to be able to search not only by a full Github repo path ("login/repoName"), but also to choose from all repos by a particular Github user.  Let's use a factory method!

A Factory method is a design pattern where objects ("products") are created through a proxy method. That function can also handle any special setup on or related to the new instance.

// test/hubbub.js
test('app.Repo.Collection.withOwner() should set the `url`', function ()
var repos = app.Repo.Collection.withOwner('bob');
equal(_.result(repos, 'url'), 'https://api.github.com/users/bob/repos');
}); 

Now, let's define withOwner() as a method on the class, which creates and returns a specialized app.Repo.Collection for us:

// collections/repo.js
Repo.Collection = Backbone.Collection.extend(
// instance methods
{
// ...
},
// class methods
{
withOwner: function (login) {
var repos = new Repo.Collection();
repos.url = app.apiRoot + '/users/' + login + '/repos';
return repos;
}
}
);

In this case, the factory method always returns an app.Repo.Collection, but you can imagine how it could create different kinds of classes, based on parameters provided, etc.

Conclusion

Backbone Collections allow you to easily group, sort and filter your models. It's important to see them as far more than an array of rows from your database. Rather collections are dynamic and live, they respond to and trigger their own events. They also often contain the URLs for syncing the client and server data. In the next chapter we will cover the use of persistence in Backbone with Sync, so that every page reload doesn't start your application over from scratch. 

There has been error in communication with Booktype server. Not sure right now where is the problem.

You should refresh this page.