switching views in backbone.js

July 12, 2013 5 min read

I've been developing with Backbone.js at work for about a year now, and it's been an adventure converting from a traditional Java server application to an all-AJAX single page application. I've run into quite a few challenges, especially with transitioning between views. There is not much information in the documentation about what steps need to be taken when matching a route and simulating a page change. Here are a few tips that might be helpful when getting started with switching views in Backbone.

Our example app

Suppose we have a very simple app with just two "pages" or views: a home page and a page that shows a list of users, and we want to switch back and forth between them. Here is some minimal sample code for this dummy application.

var HomeView = Backbone.View.extend({
    tagName: 'div',
    id: 'home-view',
    initialize: function() {
        $('body').html(this.el);
        this.render();
    },
    render: function() {
        this.$el.html(
            "<h1>This is the home page</h1><a href='#users'>Go to users</a>"
        );
    },
});

var UsersView = Backbone.View.extend({
    tagName: 'ul',
    id: 'users-list',
    initialize: function() {
        $('body').html(this.el);
        this.subViews = _.map(['Jules', 'Vincent', 'Marsellus'], function(
            user
        ) {
            return new UserView({
                model: new Backbone.Model({ id: user, name: user }),
            });
        });
        this.render();
    },
    render: function() {
        _.each(
            this.subViews,
            function(view) {
                this.$el.append(view.el);
            },
            this
        );
        this.$el.after("<a href='#home'>Go to home</a>");
    },
});

var UserView = Backbone.View.extend({
    tagName: 'li',
    initialize: function() {
        _.bindAll(this, 'alertName');
        this.render();
    },
    events: {
        click: 'alertName',
    },
    render: function() {
        this.$el.html('Hi, my name is ' + this.model.id);
    },
    alertName: function() {
        alert(this.model.id);
    },
});

var Router = Backbone.Router.extend({
    routes: {
        home: 'home',
        users: 'users',
    },
    home: function() {
        this.view = new HomeView();
    },
    users: function() {
        this.view = new UsersView();
    },
});

var router = new Router();
Backbone.history.start();

Clean up after yourself

The first thing to remember when changing views is you must explicitly call remove on the view being navigated away from. In addition to removal from the DOM, this will also unbind any events you defined on the view. If you just replace the HTML in the DOM like in the above example, any event listeners you have will remain bound to a now unreachable element. Additionally, if the view was listening for model changes, it would continue to receive events and call the attached handler. This can create some major memory leak issues.

You might wonder why we don't just store one instance of the view and re-render it on a route match, rather than create a new one every time. For this dummy example, it might work fine, but in practice when you are passing models and/or collections to your views, I've found that it's messy trying to reconfigure the view manually.

So to remove the previous view, we need to maintain a reference to the current view and remove it any time we navigate to a different view. Let's modify our router a little bit.

var Router = Backbone.Router.extend({
    routes: {
        home: 'home',
        users: 'users',
    },
    home: function() {
        this.loadView(new HomeView());
    },
    users: function() {
        this.loadView(new UsersView());
    },
    loadView: function(view) {
        this.view && this.view.remove();
        this.view = view;
    },
});

So now we ensure that the previous view is cleaned up when we transition to the next one. However, sometimes it's not enough to just call remove. Take our users view for example. It creates sub views in the form of list items that are displayed within the main view. If we just call remove on the main view, the sub views will hang around with their events still attached. You may also have events bound outside of the current view, such as the scroll event. What we can do in this case is define our own function, let's call it close, to handle anything and everything you may need to clean up.

var UsersView = Backbone.View.extend({
    tagName: 'ul',
    id: 'users-list',
    initialize: function() {
        $('body').html(this.el);
        this.subViews = _.map(['Jules', 'Vincent', 'Marsellus'], function(
            user
        ) {
            return new UserView({
                model: new Backbone.Model({ id: user, name: user }),
            });
        });
        this.render();
    },
    render: function() {
        _.each(
            this.subViews,
            function(view) {
                this.$el.append(view.el);
            },
            this
        );
        this.$el.after("<a href='#home'>Go to home</a>");
    },
    close: function() {
        _.each(this.subViews, function(view) {
            view.remove();
        });
        this.remove();
    },
});

var Router = Backbone.Router.extend({
    routes: {
        home: 'home',
        users: 'users',
    },
    home: function() {
        this.loadView(new HomeView());
    },
    users: function() {
        this.loadView(new UsersView());
    },
    loadView: function(view) {
        this.view && (this.view.close ? this.view.close() : this.view.remove());
        this.view = view;
    },
});

Checking for a dirty view

Another situation I had to account for was a page having a dirty condition, where there may be a lot of unsaved content, and we want to warn users before they navigate away from the page. Since there are no page reloads with Backbone, onbeforeunload will not work for just changing routes. We need to somehow intercept the routing event before the matching function call is made. The trick here is that jQuery calls event handlers in the order they are registered. So before we start our router, we register an event handler on hashchange, and it will be called before Backbone's handler.

// add the following function to your router
// for any view that may have a dirty condition, set a property named dirty to true, and if the user navigates away, a confirmation dialog will show
hashChange : function(evt) {
	if(this.cancelNavigate) { // cancel out if just reverting the URL
		evt.stopImmediatePropagation();
		this.cancelNavigate = false;
		return;
	}
	if(this.view && this.view.dirty) {
		var dialog = confirm("You have unsaved changes. To stay on the page, press cancel. To discard changes and leave the page, press OK");
		if(dialog == true)
			return;
		else {
			evt.stopImmediatePropagation();
			this.cancelNavigate = true;
			window.location.href = evt.originalEvent.oldURL;
		}
	}
},
beforeUnload : function() {
	if(this.view && this.view.dirty)
		return "You have unsaved changes. If you leave or reload this page, your changes will be lost.";
}

var router = new Router();
$(window).on("hashchange", router.hashChange); // this will run before backbone's route handler
$(window).on("beforeunload", router.beforeUnload);
Backbone.history.start();

So those are some basics for switching views in Backbone. It took me a while to iterate and settle on this pattern, so I hope it's a helpful example. Depending on your application, there may be other design patterns that work better. That is the double edged sword of Backbone - it's very minimal and flexible, but requires developers to customize and make more decisions. Overall though, it's an excellent framework once you get the hang of it.

© 2020 Mikey Gee