Efficient Stateful Views with Backbone.js, Part 1

One of the good things about single-page apps is that it’s possible for them to remember UI state within a page. If an area of the page is hidden from the user, and then redisplayed later, you’re able to display that area in the same state that it was left.

This is in contrast to traditional request/response apps, which are mostly stateless, and where maintaining the state of finely-detailed UIs across requests quickly becomes impractical.

However, done carelessly, retaining view state in a single-page app can result in drastic memory leaks over time as the user navigates through the UI.

I’m going to describe a strategy that we’ve used to efficiently maintain stateful Backbone.js views in a large-scale app. In this post – the first of two parts – I’m going to give an overview of the problem, introduce some memory-management infrastructure, and then propose a solution for simple views. In Part 2, I discuss how to handle collections and animated elements. Note that these posts assume experience with Backbone.

The Problem

A classic example of a UI that swaps views in and out is tabs. Consider the following simple single-page app:

You’ll see that we’ve got two tabs. The visible tab is displaying an item called ‘Bert’. You’ll notice that ‘Bert’ is clickable – if you click it, then you’ll see:

The ‘Bert’ box has expanded to include some text about Bert (in case you’re wondering, we’re talking about Bert from Sesame St).

We can also navigate to “Tab 2″, which displays nothing:

The problem is that if we now go back to “Tab 1″, we’ll see this:

The “Bert” box is back in its original state – the app hasn’t remembered that it was expanded the last time we visited “Tab 1″.

The Source

Before we start on the solution, let’s take a look at the implementation of the existing UI so that we have a reference point to work from. Firstly, there’s the HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>Stateful Backbone Views</title>
    <!-- Stripped out stylesheet info from this fragment -->
    <script src="../assets/js/jquery.js"></script>
    <script src="../assets/js/underscore.js"></script>
    <script src="../assets/js/backbone.js"></script>
    <script src="../assets/js/app.js"></script>

    <script id="expandable" type="text/x-underscore-template">
      <div class="well">
        <a href="" class="name"><%= get('name') %></a>
        <div class="detail"></div>
      </div>
    </script>
  </head>

  <body>
    <div class="container">
      <ul class="nav nav-tabs">
        <li class="tab1"><a href="#tab1">Tab 1</a></li>
        <li class="tab2"><a href="#tab2">Tab 2</a></li>
      </ul>

      <div class="content"></div>
    </div>
  </body>
</html>

and the JavaScript:

$(function($) {
  var model = new Backbone.Model({name: 'Bert', detail: 'A little uptight'});

  var Router = Backbone.Router.extend({
    routes: {
      "tab1": "showTab1",
      "tab2": "showTab2"
    },
    showTab1: function() {
      $('.tab2').removeClass('active');
      $('.tab1').addClass('active');
      $('.content').empty();
      new ExpandableView({model: model, el: $('.content')}).render();
    },
    showTab2: function() {
      $('.tab1').removeClass('active');
      $('.tab2').addClass('active');
      $('.content').empty();
      new EmptyView({el: $('.content')}).render();
    }
  });

  var ExpandableView = Backbone.View.extend({
    events: {
      'click .name': '_clickName'
    },
    render: function() {
      this.$el.html(_.template($('#expandable').text())(this.model));
      return this;
    },
    _clickName: function(evt) {
      this._expanded = !this._expanded;

      if (this._expanded) {
        new DetailView({model: this.model, el: this.$('.detail')}).render();
      } else {
        this.$('.detail').empty();
      }
      evt.preventDefault();
    }
  });

  var EmptyView = Backbone.View.extend({
    render: function() {
      // Do nothing
      return this;
    }
  });

  var DetailView = Backbone.View.extend({
    render: function() {
      this.$el.text(this.model.get('detail'));
      return this;
    }
  });

  var router = new Router();

  Backbone.history.start({ pushState: false });
});

You’ll see that we toggle between the two tabs using routes, with the first tab using a couple of nested views to display the model.

The Quick-and-Dirty Solution

A quick-and-dirty solution to the problem is to show and hide sections of the DOM using the CSS display property, rather than just dropping and redrawing them. Another approach is to pull a section out of the DOM, retain a reference to it, and then put it back later.

The problem with these techniques is that they are keeping DOM fragments in memory. Whilst this mightn’t be a problem for a small app, for larger apps, the memory footprint will increase dramatically as the user navigates through the app.

Furthermore, if you’re using an MVC-style framework like Backbone.js that allows you to subscribe to model events, keeping everything in memory will result in an ever-growing list of views, all of which respond to model changes by updating their fragment of the DOM – even if those fragments are not actually visible at the time.

A Better Solution

I’m going to propose a solution that involves retaining references to views, but also ensures that views are managed in a memory-efficient manner. I’ll illustrate the solution by extending upon the example I’ve already introduced.

Because the example is small, the approach may seem like overkill. However, the concepts are intended to be translated to much larger apps. The source code for the final, complete version of the demo is available on GitHub.

Getting Ready For Statefulness

It’s necessary to put a bit of view-management infrastructure in place before we start retaining references to views.

Whilst there are entire frameworks out there for managing Backbone views, in the interests of keeping this simple I’m going to extend upon an approach described in this excellent post on managing zombie-views, where a view base-class is introduced that allows views to be ‘closed’ by discarding DOM fragments that aren’t being displayed and unbinding from UI events. Furthermore, with a little additional work, we can extend this base class to allows us to easily and efficiently manage entire hierarchies of views. The class looks like this:

  var CloseableView = Backbone.View.extend({
    // Cleanly closes this view and all of it's rendered subviews
    close: function() {
      this.$el.empty();
      this.undelegateEvents();

      if (this._renderedSubViews) {
        for (var i = 0; i < this._renderedSubViews.length; i++) this._renderedSubViews[i].close();
      }
      if (this.onClose) this.onClose();
    }
    // Registers a subview as having been rendered by this view
    rendered: function(subView) {
      if (!this._renderedSubViews) {
        this._renderedSubViews = [];
      }

      if (_(this._renderedSubViews).indexOf(subView) === -1) {
        this._renderedSubViews.push(subView);
      }
      return subView;
    },
    // Deregisters a subview that has been manually closed by this view
    closed: function(subView) {
      this._renderedSubViews = _(this._renderedSubViews).without(subView);
    }
  });

In short, this base class provides a mechanism for a view to:

  • Cleanly discard the DOM and deregister any UI event listeners that it has set up
  • Provide a hook (onClose) for subclasses to use to deregister specific model listeners they set up
  • Keep track of all of the subviews that it has rendered, and
  • Close all rendered subviews

It’s important to note that whilst the CloseableView class is important from a memory-management perspective, it’s going to be kept orthogonal to the mechanism we’re using to store UI state – we’ll be maintaining separate view references for this. Furthermore, it’s worth noting that this sort of memory management eventually becomes a necessity whether you’re retaining views or not – it just that retaining views exacerbates the problem and makes it worth addressing sooner rather than later.

Getting Down To Business

Now that we’ve got some infrastructure in place, let’s modify our router and views to use CloseableView, and start retaining references to our views. I’ve highlighted all of the lines that have changed:

  ...

  var Router = Backbone.Router.extend({
    routes: {
      "tab1": "showTab1",
      "tab2": "showTab2"
    },
    showTab1: function() {
      $('.tab2').removeClass('active');
      $('.tab1').addClass('active');

      if (this._emptyView) this._emptyView.close();
      if (!this._expandableView) this._expandableView = new ExpandableView({model: model});
      this._expandableView.setElement($('.content'));
      this._expandableView.render();
    },
    showTab2: function() {
      $('.tab1').removeClass('active');
      $('.tab2').addClass('active');

      if (this._expandableView) this._expandableView.close();
      if (!this._emptyView) this._emptyView = new EmptyView();
      this._emptyView.setElement($('.content'));
      this._emptyView.render();
    }
  });

  var ExpandableView =  CloseableView.extend({
    events: {
      'click .name': '_clickName'
    },
    render: function() {
      this.$el.html(_.template($('#template').text())(this.model));

      if (this._isExpanded) {
        this._renderDetailView();
      }
      return this;
    },
    _clickName: function(evt) {
      this._isExpanded = !this._isExpanded;

      if (this._isExpanded) {
        if (!this._detailView) {
          this._detailView = new DetailView({model: this.model});
        }

        this._renderDetailView();
      } else {
        this._detailView.close();
        this.closed(this._detailView);
      }

      evt.preventDefault();
    },
    _renderDetailView: function() {
      this._detailView.setElement(this.$('.detail'));
      this._detailView.render();
      this.rendered(this._detailView);
    }
  });

  var EmptyView = CloseableView.extend({
    ...
  });

  var DetailView = CloseableView.extend({
    ...
  });
  ...

There’s quite a few things that have changed here, so we’ll step through them one by one.

Firstly, all of the views now extend CloseableView. Furthermore, we always get a parent view that has just rendered a child view to immediately register that child as having been rendered by calling the rendered() method. If we don’t do this, there’s a chance the child view won’t get cleaned up properly if/when the parent gets closed. Similarly, if a child view gets manually closed by its parent (for example in ExpandableView._clickName), then we should ensure that the parent deregisters it using the closed() method. That way, if the parent gets closed, it won’t try to automatically close the child again.

Secondly, you’ll notice that, instead of creating new views every time, we now only create an instance if one hasn’t been created before, and then reuse that instance. For example, Router now has _expandableView and _emptyView properties, and the ExpandableView has a _detailView property.

You may be wondering why views need to retain their own references to subviews when CloseableView can also do it for us via the rendered() method. The reason is that we want to retain references even when child views are not rendered – that’s how we retain the state of views that aren’t visible.

You’ll also note that none of the views are initialized with an el option anymore. Instead, we explicitly call setElement() on them before they get rendered. The selector used to get the element may be the same each time, but the actual element object will be different – if we were to close a parent and then re-render it again, then its children need to be attached to a newly rendered element, not an old element that has been removed from the DOM.

Finally – and perhaps most importantly – ExpandableView.render() now renders the view in an expanded state if it has previously been expanded. Now, if we expand out the view on the first tab, then shift to the other tab and back, it’ll still be displayed in an expanded state.

Now if we expand ‘Bert':

switch to “Tab 2″:

then go back to “Tab 1″, we’ll see this:

Joy!

Nesting

The good thing about this pattern is that it can be used with nested UIs. For example, because DetailView doesn’t actually have any state, strictly speaking it isn’t necessary for ExpandableView to maintain a _detailView reference. However, if DetailView did become stateful, then that state would be preserved. Furthermore, if DetailView then got it’s own children and those wanted to preserve state, the same pattern could be applied recursively.

That concludes the first part of this series on maintaining efficiently maintaining stateful views with Backbone.js. In Part 2, I’ll discuss how to cope with collections, why you need to have special handling of animated elements, and why I’ve chosen this overall approach instead of other alternatives.

About Ben Teese

I'm a Senior Consultant at Shine Technologies. You can find me on Twitter and GitHub.
This entry was posted in AJAX, Javascript, UI and tagged , . Bookmark the permalink.

6 Responses to Efficient Stateful Views with Backbone.js, Part 1

  1. Pingback: Efficient Stateful Views With Backbone, Part 2: Collections & Animations | The Shine Technologies Blog

  2. Pingback: An Identity Map for Backbone.js | The Shine Technologies Blog

  3. Rob Levin says:

    Interesting approach thanks for sharing. I’m wondering if views newing up sub views is the best approach though … I see it as common practice in the wild but wonder if an app.js could use DI and inject any depended on sub views etc. and then manage the disposal of those sub views by calling their close method (as I agree that they should be responsible for any custom unwinding required).

  4. Pingback: Testing Backbone Views with QUnit and Sinon | Shine Technologies

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s