Efficient Stateful Views With Backbone, Part 2: Collections & Animations

Posted by by Ben Teese

In Part 1 of this blog series, we saw how UI state could be maintained efficiently in a Backbone app by introducing view-management infrastructure and retaining references to views. In Part 2, I’m going to talk about how this strategy can be extended to views of collections. I’ll also discuss some alternate approaches to the problem of efficient stateful views, and why I chose the approach of retaining references to views. Finally, for bonus points I’ll discuss how to manage portions of your UI that transition their state via animations.



Collections

The strategies I described in Part 1 are great for simple views, but what about views where we’re displaying a collection? Well the good news is that it’s possible, the bad news is that it’s a little more involved than you might have guessed if you want to do it properly.

Let’s make it that “Tab 2” no longer displays an empty page, but instead displays a collection of models:

Each of which can be expanded:

A stateless implementation of this is reasonably straightforward – we can just introduce a ListView class that leverages the existing ExpandableView , and use it in our Router instead of an EmptyView :

... var model = new Backbone.Model({name: 'Bert', detail: 'A little uptight'}); var collection = new Backbone.Collection([ model, new Backbone.Model({name: 'Ernie', detail: 'More relaxed'}) ]); var Router = Backbone.Router.extend({ ... showTab2: function() { $('.tab1').removeClass('active'); $('.tab2').addClass('active'); if (!this._listView) this._listView = new ListView({collection: collection}); if (this._expandableView) this._expandableView.close(); this._listView.setElement($('.content')); this._listView.render(); } }); ... var ListView = CloseableView.extend({ render: function() { this.$el.empty(); this.collection.each(function(model) { this.$el.append(new ExpandableView({model: model}).render().$el); }, this); } }); ...

Of course, as well as not remembering which rows have been expanded, this implementation is also subject to memory management problems, because it never closes the child views that it creates. Here’s a first cut at dealing with both of these issues:

... var ListView = CloseableView.extend({ render: function() { if (!this._expandableViews) { this._expandableViews = []; this.collection.each(function(model) { this._expandableViews.push(new ExpandableView({model: model})); }, this); } this.$el.empty(); _(this._expandableViews).each(function(expandableView) { this.$el.append(expandableView.render().$el); this.rendered(expandableView); expandableView.delegateEvents(); }, this); } }); ...

So now we’re maintaining a reference to a collection of ExpandableViews . Note also that we call delegateEvents() on each view after we’ve rendered it. This is so that each view can set up any event listeners to work in the newly rendered DOM (we can’t use setElement() to do this because views are being appended to the parent element).

So now we’ve got expandable views – but there’s a problem. What if the collection changes between renders? Even if we listened to the collection whilst the view was visible and re-rendered if there were changes, we’d need to stop listening once the view was closed if we wanted to avoid memory-management issues. Furthermore, if the collection changes, we don’t want to discard those child views that correspond to models that are still in the collection – those views may have their own state that needs to be preserved (for example, whether they’ve been expanded or not).

We need a way to synchronise the views and models whenever we’re rendering – ie, to remove views for models that are no longer in the collection, as well as to add views for models that are new to the collection. So without any further ado, let’s extend render() to do this:

... var ListView = CloseableView.extend({ render: function() { if (!this._expandableViews) { this._expandableViews = []; } var modelsWithViews = _(this._expandableViews).pluck('model'); var modelsThatNeedViews = _.difference(this.collection.models, modelsWithViews); _(modelsThatNeedViews).each(function(model) { this._expandableViews.push(new ExpandableView({model: model})); }, this); var modelsWhoseViewsShouldBeRemoved = _.difference(modelsWithViews, this.collection.models); _(modelsWhoseViewsShouldBeRemoved).each(function(model) { // Find the view to be removed var expandableView = _(this._expandableViews).find(function(expandableView) { return expandableView.model === model; }); this._expandableViews = _(this._expandableViews).without(expandableView); }, this); this.$el.empty(); _(this._expandableViews).each(function(expandableView) { this.$el.append(expandableView.render().$el); this.rendered(expandableView); expandableView.delegateEvents(); }, this); } }); ...

Now our view will be able to handle any sort of changes that happen to the collection between renders. Furthermore, we can re-appropriate this logic for use by an event handler whilst a view is visible – for example, if a ‘reset’ event were to occur on the collection:

... var ListView = CloseableView.extend({ render: function() { this._renderList(); this.collection.on('reset', this._reset, this); }, onClose: function() { this.collection.off('reset', this._reset, this); }, _reset: function() { _(this._expandableViews).each(function(expandableView) { expandableView.close(); this.closed(expandableView); }, this); this._renderList(); }, _renderList: function() { // Same render logic as before ... } }); ...

Note how, on a ‘reset’, we close down all of the child views manually before re-rendering the list. This is necessary to ensure there aren’t any leaks. Also note how we leverage the onClose function to stop listening to the collection when the ListView itself is closed. If the view gets rendered again later, we’ll just redraw the list from the latest state of the collection.

Alternate Approaches

It’s worth noting that there are a couple of alternate approaches to the strategies that I’ve described in these posts.

One alternative is to store UI-related attributes in the models themselves. For example, if a model were expanded in a particular view, we might add an attribute named expanded to it and give it a value of true . This would be advantageous when dealing with collections because there wouldn’t be a need to synchronise views with models.

However, the downside of this is that it becomes difficult to keep track of all of these new attribute names in your model as your UI grows. For example, in my demo, both “Tab 1” and “Tab 2” are displaying separate views for the “Bert” model, each of which can be expanded independently. If these expansion-states were stored on the model as attributes, the attributes would have to be called something like expanded_on_tab1 or expanded_on_tab2 . Clearly this approach won’t scale to more complex UIs. I’d prefer to contain such state to the views themselves, rather than pollute the attribute namespace of the model.

Another approach is to add an additional layer of models for storing UI state. However, given that we already have a view layer, I’d rather leverage the views instead. As long as you manage them correctly, you can avoid memory leaks. Furthermore, an additional layer of models would be subject to the same collection synchronisation issues that we’ve seen between views and models. In short, I’m not clear what the advantage of a dual-model-layer approach is.

Getting Animated

Instantly showing and hiding details when the user clicks the name can be a little jarring from a UX perspective. Consequently, we often use animations to transition things in and out. To do this, we first have to make it that when the details get rendered, they’re not actually visible. Here’s the updated HTML:

... <script id="template" type="text/x-javascript-template">// <![CDATA[ <div class="well"> <a href="" class="name"><%= get('name') %></a> <div class="detail" style="display:none;"></div> </div> // ]]></script> ...

Now we can update ExpandableView to control the visibility of the DetailView :

... var ExpandableView = CloseableView.extend({ ... render: function() { this.$el.html(_.template($('#template').text())(this.model)); if (this._isExpanded) { this._renderDetailView(); this._detailView.$el.show(); } return this; }, _clickName: function(evt) { this._isExpanded = !this._isExpanded; if (this._isExpanded) { if (!this._detailView) { this._detailView = new DetailView({model: this.model}); } this._renderDetailView(); this._detailView.$el.slideDown(); } else { this._detailView.$el.slideUp(_.bind(function() { this._detailView.close(); this.closed(this._detailView); }, this)); } ... }, ... }); ...

Let’s look at _clickName first. We employ regular jQuery animations to slide down the view element when the view is being expanded, and slide it back up when it’s being compressed. Note how we use a callback on slideUp() to ensure that the animation finishes before we close the view and pull it out of the DOM. Otherwise, it’ll disappear from the DOM before the animation finishes – which kind of defeats the purpose.

Now let’s look at render() . When we’re rendering a view that’s already open, we don’t want it to re-animate the opening. Consequently, we just use show() to make it visible immediately. It’s important that you consider this alternate behaviour up-front when you’re designing your views. Fitting it in retrospectively can result in a lot of rework.

Wrapping Up

In this series we’ve seen how Backbone can be used to create views that remember the state that they were left in. This improves the user experience dramatically and is a definite advantage that single-page apps have over traditional request-response web apps. Furthermore, used in conjunction with a Backbone router, this UI state can even be retained through back-button clicks and the like – everything barring a full-page reload.

The only downside to storing state is that will affect how you construct your views. Consequently, it’s worth considering sooner rather than later whether you want stateful UIs to be a feature of your app.

Thanks to Marc Fasel, Nick Letts and Brent Snook for their feedback on early drafts of this blog.