Top-down Backbone Routers and application state

By Dave Elkan

I’ve seen a lot of Backbone Routers over the years working with Backbone. Here’s what I’ve found is the best way to use them effectively.

Note: This is code is just an example. To see a working example you can run yourself head over to the github repo or check out the live demo.

var Router = Backbone.Router.extend({
    routes: {
        'foo': 'foo',
        'bar': 'bar'
    },

    navigateToAndTrigger: function(url) {
        this.navigate(url, {
            trigger: true
        });
    }
});

var AppModel = Backbone.Model.extend({
    onStateSelected: function(state, args) {
        this.set('state', state);
    }
});

var AppView = Backbone.View.extend({
    events: {
        'click a': '_navigateTo'
    },

    initialize: function(state, args) {
        this.listenTo(this.model, "change:state", this.onStateChange);
    },

    onStateChange: function(state) {
        this.$el.removeClass(this.model.previous('state'));
        this.$el.addClass(this.model.get('state'));
    },

    _navigateTo: function(e) {
        this.trigger('navigate', $(e.target).attr('href'));
        e.preventDefault();
    }
});

var router = new Router();
var appModel = new AppModel();
var appView = new AppView({
    model: appModel,
    el: '#app'
});

// Wire up the model to listen to the router for every route.
router.on('route', appModel.onStateSelected, appModel);
appView.on('navigate', router.navigateToAndTrigger, router);

The Router

var Router = Backbone.Router.extend({
    routes: {
        'foo': 'foo',
        'bar': 'bar'
    },
    navigateToAndTrigger: function(href) {
        this.navigate(url, {
            trigger: true
        })
    }
});

One key rule: Routers only know about routing.

This router only knows we can route to the foo and bar urls. It doesn’t contain references to views or models. it is the single source of truth as to which routes the application accepts.

The app model

var AppModel = Backbone.Model.extend({
    onStateSelected: function(state, args) {
        this.set('state', state);
    }
});

The app model maintains one simple state variable which represents the state of the current application. The model doesn’t know anything about the router or routes.

The app view

var AppView = Backbone.View.extend({
    initialize: function(state, args) {
        this.listenTo(this.model, "change:state", this.onStateChange);
    },
    onStateChange: function(state) {
        this.$el.removeClass(this.model.previous('state'));
        this.$el.addClass(this.model.get('state'));
    }
});

AppView reacts to changes in the app state. In this case, adding a class on the parent element with the intention of showing and hiding child elements.

Instantiation and wiring

var router = new Router();
var appModel = new AppModel();
new AppView({
    model: appModel,
    el: '#app'
});

// Wire up the model to listen to the router for every route.
router.on('route', app.onStateSelected, app);

Here we simply instantiate the Router, Model and View and link them together. The model reacts to every route event.

Top down - Don’t short circuit

Life’s easier with less code paths so do yourself a favour and always propagate state changes from URL down. That way whether loading a page for the first time, or when the user clicks a link or performs an action, the code path is the same.

For example, you may find yourself writing a click handler which updates the URL to match the state of your app:

var AppView = Backbone.View.extend({
    events: {
        "click .nav a": "_handleNavClick"
    },
    _handleNavClick: function(e) {
        var $anchor = $(e.target).closest("a");
        this.model.selectNavItem($anchor.data("id"));
    }
});

var AppModel = Backbone.Model.extend({
    selectNavItem: function(id) {
        // Select the nav item with id
        ...
        // Update the URL to match the id
        Backbone.history.navigate("/section/" + id);
    }
});

When you call Backbone.history.navigate without a second parameter it does not trigger the route event. In this case we’re simply updating the URL to match the state of the app. It should be the other way around.

Also, I consider referring to the global Backbone.history.navigate as bad practice. AppModel should know nothing of routes or routing.

Instead, just create anchors with a proper href and navigate to them. i.e.

var AppView = Backbone.View.extend({
    events: {
        'click a': '_navigateTo'
    },
    _navigateTo: function(e) {
        this.trigger('navigate', $(e.target).attr('href'));
        e.preventDefault();
    }
});
var router = new Router();
var appView = new AppView({
    el: '#app'
});
appView.on('navigate', router.navigateToAndTrigger, router);

Complicated state

This is a very simple example. What if you have a complicated form, which when submitted, stores it state in the URL allowing people to share the results? Try something like this:

var Router = Backbone.Router.extend({
    navigateToSearch: function(data) {
        var url = ...
        // Calculate the URL for the search data.
        // Ideally this would be performed by a dependency of the router instead of the router itself.
        this.navigateToAndTrigger(url);
    }
});

var SearchFormView = Backbone.View.extend({
   events: {
       'submit': '_onFormSubmit'
   },

   _onFormSubmit: function(e) {
       this.trigger('search', this.$el.serializeArray());
       e.preventDefault();
   }
});

var searchFormView = new SearchFormView({
    el: "form"
});
var router = new Router();

searchFormView.on('search', function(data) {
    router.navigateToSearch(data);
});

For the sake of brevity, searchFormView is emitting a search event. In more realistic situations it is more likely that the view would use its model as a vent. This is the way it is in the example app.

Please feel free to check out the repo or live demo of the concepts shown in this post.

comments powered by Disqus