Building Backbone Plugins: Eliminate The Boilerplate In Backbone.js Apps (2014)
PART 3: DATA AND META-DATA
Chapter 13: A Filtered Collection
Displaying a list of items is one of the most common tasks in any application - and JavaScript and Backbone.js apps are no exception.
Backbone generally makes these two tasks easy. It provides the concept of a Collection to manage a list of items, and you can assign a collection to a View instance for display. But what happens when you need to filter that collection, and only display a subset of what it contains? And what if that sub-set changes when a user selects certain options or types something new in to a search box?
Filtering an in-memory collection is simple, sure. The collection has built in methods to do this. But filtering a collection and then rendering the results can create some unexpected challenges. The journey to find simple and clean code can be unexpectedly difficult.
Basic Filtering
There are a handful of methods built in to Backbone.Collection that allow you to do various forms of filtering and searching. Most of these methods come from Underscore.js, but some of them don’t. Regardless of where they come from, the majority of these methods have one thing in common: they return an array of models from the collection.
Look at the where method for example. When you call this method, you provide a set of filters on model attributes. Any model that matches the specified attribute and value combinations will be returned in an array.
1 var myCollection = new Backbone.Collection([
2 {a: "first", b: "second"},
3 {a: "third", b: "second"},
4 {a: "first", b: "fourth"},
5 {a: "second", b: "third"}
6 ]);
7
8 var results = myCollection.where({
9 a: "first",
10 b: "fourth"
11 });
The results variable will be an array, with only 1 item in it - the item that matches both a: "first" and b: "fourth". Having the result list is great, but now you need to display the list in the app.
View-Controlled Filtering
Displaying a filtered list is fairly easy. The only catch is that you don’t have a collection coming back from the collection’s filtering methods. You have an array of objects, instead. This tends to catch people by surprise. What do you do with this array? How do you get a Backbone.View to display it, without it being a Backbone.Collection? This becomes especially confusing when the view that should be showing the filtered list is already holding a reference to the original collection.
It would be easy, for example, to call the .where method in your view. After all, it’s the view that has the collection and the user interactions happen through the view, so why not handle the filter in there, as well?
Using a BBPlug.CollectionView, you would override the serializeData method to return the filtered object list.
1 var MyItemView = BBPlug.ModelView.extend({
2 template: "#some-model-template"
3 });
4
5 var FilteredView = BBPlug.CollectionView.extend({
6 modelView: MyItemView,
7
8 events: {
9 "click #run": "runFilter"
10 },
11
12 // convert the collection in to an array of
13 // items that has been filtered to match the
14 // specified criteria
15 serializeData: function(){
16 return this.collection.where(this.filter);
17 },
18
19 runFilter: function(e){
20 e.preventDefault();
21
22 this.filter = {
23 // ... build the filter info from
24 // the user input and other settings
25 };
26
27 this.render();
28 }
29 });
Things are looking good at this point. The code is small, easy to understand and it gets the job done.
This code works for simple scenarios as outlined here. If you’re dealing with simple scenarios, there might not be anything wrong with doing this. But there are monsters lurking around the corners - and they are only waiting for the next feature or next requirement for filtering, to jump out and eat your code and productivity.
Spaghetti Monsters
What happens when you need to change how the filtering works? Instead of just calling .where, you need to use other methods of filtering? What happens if some other part of the app sets up a list of default or global filters? How do you handle the need to get those other filter settings, with potential for additional method calls to get the right results in place?
Pretty quickly the FilteredView that you’ve created begins to become a behemoth of far too many responsibilities. You now have logic for setting up filters (including default or global filters), running the filters, converting the filtered list in to HTML, displaying the resulting list, and handling interactions with that list. Now try to re-use this setup in other areas of the application where similar filtering needs to happen. Or try to use the same logic to get a filtered result set that doesn’t need to be displayed at all. Maybe it just needs to be available so that some other code can find the item it needs from the filtered list. What do you do, then? How do you get the filtering to work in those scenarios, and in a consistent manner that doesn’t create a spaghetti-mess of code duplication?
Filtering collections in the view that displays the results will get out of hand, quickly. You’ll end up with a flying spaghetti monster of tangled responsibilities, tearing apart your productivity and ability to create new features without adding new bugs or duplicate code.
Self-Filtering Collections
Moving the logic of how to apply a filter into the collection itself, is generally a good idea. This gives you purpose-built collections that can encapsulate the logic and process that you need, behind an easy to use API. But truth be told, most collections don’t need anything special. The Backbone.Collection type has the majority of use cases for filtering covered already. You just call the existing methods, as shown the filtering example above.
Separating the responsibilities of filtering vs render is important, though. But even with separation, there are additional problems for which you need to watch out. Many projects have seen code go down in the flames of entanglement and unexpected behaviors because the attempt to separate concerns led to other problems. In the case of filtering collections, watch out for the self-filtering collection anti-pattern.
The Self-Filtering Anti-Pattern
If you tried to have the original collection filter itself and then update itself with the filtered results, you would end up in a worse position than having the view in control of filtering.
Take the collection from the filtering example above as an example:
1 // custom collection type with custom filtering
2 var MyCollection = Backbone.Collection.extend({});
3
4 // collection instance, with data
5 var myCollection = new MyCollection([
6 {a: "first", b: "second"},
7 {a: "third", b: "second"},
8 {a: "first", b: "fourth"},
9 {a: "second", b: "third"}
10 ]);
Now add a custom filter method to this collection and update the same collection instance with the results:
1 // custom collection type with custom filtering
2 var MyCollection = Backbone.Collection.extend({
3 customFilter: function(filters){
4 var results = this.where(filters);
5 this.reset(results);
6 }
7 });
8
9 // collection instance, with data
10 var myCollection = new MyCollection([
11 {a: "first", b: "second"},
12 {a: "third", b: "second"},
13 {a: "first", b: "fourth"},
14 {a: "second", b: "third"}
15 ]);
16
17 // filter the collection
18 myCollection.customFiler({
19 a: "first"
20 });
Once you have filtered the collection with the custom method, there are only two items left in the collection. This appears to be the desired result, as only the items with an attribute of a: "first" are available now. If you try to apply a new filter, though, it will only run against the items that are currently in the collection … the items that were already filtered.
1 myCollection.customFilter({
2 a: "first"
3 });
4
5 myCollection.customFilter({
6 b: "second"
7 });
This code runs fine, but it won’t produce the results that most users would expect. Instead of re-running the filter against the original list, the second run filters the already filtered items, resulting in zero items to show or use. Any additional filter calls will continue to return zero items, even if the user changes the filter back to the original input that returned two items.
This method has made it very difficult to filter the original collection more than once. The only way to fix this and get the expected results is to reset or reload the collection with the original objects. This is lot of unnecessary work.
The solution, then, is not to update the collection that is being filtered. Instead, allow the collection to return the filtered list as it has been doing. But you may not want to deal directly with a JavaScript array, either.
Pure Filtering And Clean Collections
The name of the game, here is Separation of Concerns. Keeping the filtering process separate from the filtering results is important. It is just as important as keeping the filtering process out of the view that displays the filtered results.
But having the filtering process separated from the filtering results doesn’t mean you have to deal with a raw array of models after applying the filter. Having a Backbone.Collection is useful, after all. And there are times when it makes sense to have a collection return another instance of a collection from a filtering method.
Creating A Filtered Collection
If you want to use the standard Backbone.Collection features for your filtered list, you have to convert the array in to a Backbone.Collection. This results in two collection instance for filtering: the original collection, and a filtered collection. Once you have the filtered collection, you can pass it to a Backbone.View instance and work with it like any other collection.
Creating a filtered collection instance is as easy as calling new Backbone.Collection with the filter results.
1 var results = myCollection.where({
2 a: "first"
3 });
4
5 var filteredCollection = new Backbone.Collection(results);
Yes, it’s that easy. Go forth and be merry… well… maybe there’s a few more things you could add, to make this even more awesome?
Returning The Same Collection Type
You might want to have the custom collection type return an instance of the same type from a custom filtering method.
1 // custom collection type with custom filtering
2 var MyCollection = Backbone.Collection.extend({
3
4 customFilter: function(filters){
5 var results = this.where(filters);
6 return new MyCollection(results);
7 }
8
9 });
10
11 // ... create myCollection instance, here ...
12
13 var filteredCollection = myCollection.customFilter({
14 a: "first"
15 });
The advantage here, is having an instance of the same collection type with all of it’s methods and capabilities, but those behaviors will only be applied to the filtered list. This can be a very powerful tool when you need to process a list of filtered results, ignoring anything that doesn’t match the criteria.
There is a disadvantage to this, though. Every time you call the custom filtering method, you end up with a new collection instance as the result. This can impose yet another problem in updating the filtered view with the new list of items. You will have to inject the new collection in to the existing view, or replace the view entirely. Neither of these options is optimal for very many situations.
Hang on to this pattern, though. There are scenarios where it is useful and you’ll want to have it in your tool box for the times where it does make sense.
Rendering The Filtered Collection
With the filtered collection in hand, create a view that knows how to render a collection. Keep in mind that this view does not need to know anything about how the filters are applied to the collection, though. It only knows that it receives a collection and renders it.
Use the existing BBPlug.View and BBPlug.CollectionView to render the new collection.
1 var MyItemView = BBPlug.ModelView.extend({
2 template: "#some-model-template"
3 });
4
5 var FilteredView = BBPlug.CollectionView.extend({
6 modelView: MyItemView
7 });
8
9 var filteredView = new FilteredView({
10 collection: filteredCollection
11 });
12
13 filteredView.render();
14 $("#some-element").html(filteredView.$el);
This will render the list of items in to the #some-element DOM element. The result will be the display of the filtered list, as expected.
Note that you no longer have to override the serializeData method, either. The collection that is being rendered is already filtered so there is no need to do this.
Displaying the list is a good start, but it is likely that you’ll need to update the list as well. To handle this, you’ll need a little more code in the FilteredView.
Updating The Filtered List
When the filter options change, you’ll need to update the result set and view to account for the changes. The best way to handle this is with a single filtered collection instance - one to hold the filtered results no matter how many times it changes - and update the view when the filtered collection’s contents change.
Responding to the collection changing is only a matter of listening for a “reset” event on the collection. When the filter options change, call .reset on the collection, passing in the new result set. The “reset” event will trigger after the collection has been updated, and the view that is rendering the collection can re-populate the DOM with the new results.
1 var filteredCollection = new Backbone.Collection();
2 var filteredView = new FilteredView({
3 collection: filteredCollection
4 });
5
6 // ... render and display the filtered view, here ...
7
8 // handle the filter changes when the user
9 // clicks a "run" button
10 $("#run").click(function(e){
11
12 // retrieve the list of filters to use
13 // the details of this are not important,
14 // other than they be formatted as shown
15 // in the original example, above
16 var filter = {
17 // ...
18 };
19
20 // get the new list of results from
21 // the new filter for the collection
22 var results = myCollection.where(filter);
23
24 // reset the filtered collection so that
25 // it will update and show the new list
26 filteredCollection.reset(results);
27 });
Now when the user changes the filter and clicks the “run” button, the filtered collection will be reset and the filtered view will re-render itself with the new results.
The original collection is still in place, as well. You haven’t modified it at all. This means additional filtering will still produce correct results - results that come from the original collection, and not just a sub-set of the previous filter results.
Using An Array, Not The Collection Type Did you notice that the code to update the filtered list skipped the custom filter method - the one that returned a new instance of the same collection type? Instead, this code opted to call the where method directly, receiving an array of items as a result. If you tried to use the .customFilter method, you would create a new collection instance for every filter change. To handle this, you would either have to create a new view instance every time or inject the new collection in to the existing view and then tell the view to re-attach any event handlers and other code that deals with the collection. This is overhead that you don’t need since you will be updating the filtered collection instance with the results anyways. |
Lessons Learned
With the built in filtering methods, Properly filtering a Backbone.Collection is fairly simple. The journey to get to simple rendering seems to be anything but, however.
Looks Can Be Deceiving
The simplicity of the typical use case for a collection and view combination presents a number of challenges when dealing with filters. Having too many concerns wrapped up in the view, returning a collection instance from the filter methods, or trying to update the collection that is being filtered with the results, all seem like reasonable approaches at first. But the subtle differences in usage patterns and behaviors for rendering filtered collections creates a need for a distinct implementation.
Filter Separate From The Collection
Ultimately, the collection from which you are trying to produce filtered results, should be kept separated from the actual result set. Attempting to filter the original collection will result in a situation where you can only apply a single filter, with additional filters or changes producing incorrect results.
Handling The Click In The Right Place
Having too many concerns wrapped up in the view, returning a collection instance from the filter methods, or trying to update the original collection with the filter results all seem like reasonable approaches at first. But the subtle differences in usage patterns and behaviors for rendering filtered collections creates a need for something a little different.
The filtering mechanism needs to remain separate from the rendering. And the results of the filter should also remain independent of the original collection. Keeping all of these parts separated will keep your code clean and create opportunities for re-using the filtering or rendering logic.