"triangles"
— or —
"the shape of an application"

mvc has become a bit of a buzzword </understatement>. it's a complicated, nuanced concept, and even if you're using an mvc framework, it's easy to get wrong.

a year and a half ago i was interviewing for my first job as a web developer, and the question came up:

what is mvc design?

i probably regurgitated something i had read in a blog post that sounded something like:

models are where you put your business logic.

while that may be true, it only applies to a specific use case. it glosses over the deeper concept, and as a result i made some poor design choices when i found myself in unfamiliar design territory.

regardless of your mv* flavor (mvc, mvp, mvvm, …) one of the foundational concepts that you must get right is separating and isolating state, whether it's the number of products in a user's shopping cart or the todo item the user is editing.

bringing it down to earth

we'll look at a real world example that might tempt a developer to put state where it doesn't belong, and then apply mv* concepts and see the benefits.

say you're one of the top-flight engineers on the sparrow team, and you've just been acquired by google. your assignment is to create a web application that measures up to the native mac and ios implementations.

high level stuff

you do all the typical wireframing stuff and end up with some wires that look like this:

you start banging out some code and come up with a view hierarchy that looks like this:

this is simplified for the sake of instruction, but basically you've got a view for the list of e-mails on the left, and a view for the selected message on the right. these are composed in a full mailbox view. the view hierarchy presumably reflects the dom hierarchy.

first pass

a naïve way to hook all of this up may involve the code below (i'm using backbone.js, because it's api is hopefully intuitive, but the concepts apply regardless of the framework).

the problem

this design may seem harmless, like it's leveraging mv* concepts with the views and the models, but the problem is we have not isolated application state in the model layer. the page should track which e-mail is selected in one and only one place.

as we'll see, leaving state around like this makes things complicated as the application grows.

the geometry of a poor design

one hallmark of this sort of design is the communication triangle that forms between the parent view and its children:

the parent element should not need to be involved for this communication. this gets especially bad with heavily-nested view hierarchies. additionally, in my experience, this pattern encourages a lot of ad-hoc method names like getMessageModelFromDOMEl and setMessage which make it difficult to grok someone else's code.

the fix

a more mv* approach would track the selected mail message in the model layer. the MessageListView would simply set an attribute on a model in a collection, and the MessageView would listen for changes on that same collection. all the parent view has to do is make sure they've each got a reference to the same collection.

var MessageListView = Backbone.View.extend({
    events: {'click li': 'onMessageClick'},

    constructor: function() {
        this.collection.on('change', this.onChange, this);
    }

    // ...

    onMessageClick: function(evnt) {
        var selectedMessage;

        this.collection.each(function(message) {
            // unselect everything, don't trigger any events
            message.set('selected', false, {silent: true});

            // find the selected model
            if (message.get('id') === $(evnt.target).data('message-id')) {
                selectedMessage = message;
            }
        });

        // this will trigger a change event on the collection
        selectedMessage.set('selected', true);
    }

    // re-render the view, displaying the newly selected e-mail
    onChange: function() {
        this.model = this.collection.where({selected: true})[0];
        this.render();
    }
});

var MessageView = Backbone.View.extend({
    constructor: function() {
        this.collection.on('change', this.onChange, this);
    }

    // ...

    onChange: function() {
        this.model = this.collection.where({selected: true})[0];
        this.render();
    }
});

now the collection serves as a single point of truth for determining which email is selected.

one rule of thumb we see emerging here is that the model layer should be responsible for publishing events to disparate pieces of code. when a view is responsible for tracking state like which resource a user selected and another view needs to be notified of changes, the information must go all the way up through a common ancestor in the view hierarchy and back down.

and the payoff

now pretend that new features have crept into the requirements (i know, right?), and when the user refreshes the page, our app must load the message the user was previously viewing. this is an excellent chance to make use of the html5 history api, especially since it would give us client-side routing for free (eek!).

when the user selects a message, we'll update the url with a slug for the message's subject. so our wireframes would become something like this (notice the updated url):

this feature introduces a second means of setting the selected message (via the url), and in our first design it becomes extremely tricky to correctly render the views: the parent has to decide whether to update the MessageView based on the MessageListView or the url. the views are tightly coupled, and as a result every time we add new functionality, we find ourselves having to re-work parts of the existing code.

however our second design handles this much more elegantly. we don't even need to touch the existing code, just add a bit of routing code to the top level of our application:

window.addEventListener('popstate', function(e) {
    // `emails` is the same collection the views reference
    emails.each(function(message) {
        message.set('selected', false, {silent: true});
        if (message.get('id') === getIdFromUrl()) {
            selectedMessage = message;
        }
    });
    selectedMessage.set('selected', true);
});

emails.on('change', function() {
    var selectedMessage = emails.where({selected: true})[0];
    history.pushState(null, null, makeUrlFromModel(selectedMessage));
});

the app is really leveraging mv* design concepts now. the model layer gives us a way to loosely couple components, such that we can integrate features into the app without having to modify existing code. that's powerful.

bringing it back up

these concepts are core to mv* design, and while the approach is different depending on the framework (for instance ember introduces a state manager), the key is to isolate state, and use that to drive updates in your views.

blog comments powered by Disqus
© aaron stacy 2012, all rights reserved