Knockout.js - it's not all view models

Knockout.js - it's not all view models

Here at Picnic Software we are big fans of the knockout.js framework for creating the rich browser interfaces in our applications.

Today I would like to show how we arrange our view models. Knockout.js makes it very easy to create a user interface that dynamically reflects changes that occur in the View Models of your application. Adding a new item to a collection - bam it also appears in your list on screen. Got some logic to decide if something should be hidden - wrap it in a function, bind it to the UI and watch it react as the data changes.

One area that took us a while to master was how best to deal with UI in different parts of the screen the are reflections of the same data.

A contrived example might look like this:

Example Screen

The header area has the ability to lock the whole screen as well as display the current number of items. When the screen is locked we want the add textbox and button to dissapear. When an item is added we want the count to be incremented. On a simple page we can easily bind the same view model to both areas or even bind the view model the whole page. As screens get more complicated this gets more and more difficult to manage. Eventually you split your view models but you now have the challenge of how to share state between your header view model and your list view model.

One pattern we have found very effective for this is to have an underlying shared model. We'll call it the data model, some people might think of this as a domain model or an application model. The data model is very simple - it doesn't have to suit the needs of any particular screen just be the container for the page state. Our page model would look like this:

var DataModel = function(){
    this.todos = ko.observableArray(
        ["First Item", "Second Item"]
    );
    this.locked = ko.observable(false);
}

As you can see this model could not be any simpler. Now that we have a data model for our view models to share we can have seperate view models that precisely match the needs of the parts of the page that use them.

Our list view model might look something like this:

var ListViewModel = function(dataModel){
    var self = this;

    this.todos = dataModel.todos;
    this.locked = dataModel.locked;

    this.newTodoText = ko.observable();

    this.addNew = function(){
        dataModel.todos.push(self.newTodoText());
        self.newTodoText("");
    }
}

The data model is passed into the constructor for the view model. In the case of the todos and locked properties we just reuse the observables from the data model. When we add a new item in the addNew function we add it to the data model's list - because our list uses the same observable our list will be udpated.

Similarly our header view model can also take the data model as a constructor parameter:

var HeaderViewModel = function(dataModel){
    var self = this;

    this.count = ko.computed(function(){
        return dataModel.todos().length;
    });

    this.showUnlockButton = ko.computed(function(){
        return dataModel.locked();
    })

    this.showLockButton = ko.computed(function(){
        return !dataModel.locked();
    })

    this.lock = function(){
        dataModel.locked(true);
    }
    this.open = function(){
        dataModel.locked(false);
    }
}

Since both view models share the same data model there is no need to pass state back and forth. When the list view model above adds a new item it will force the count in the header to be re-computed. Similarly if the user presses the lock or unlock button in the header that state will be reflected back in the list view model.

We have found there are many ways to share state between view models but this pattern has turned out to be the simplest in many parts of our application. It keeps our view models focussed on the screen and ensures that the state is all stored in one place.

The full code:

var DataModel = function(){
    this.items = ko.observableArray(
        ["First Item", "Second Item"]
    );
    this.locked = ko.observable(false);
}

var ListViewModel = function(dataModel){
    var self = this;

    this.items = dataModel.items;
    this.locked = dataModel.locked;

    this.newTodoText = ko.observable();

    this.addNew = function(){
        dataModel.items.push(self.newTodoText());
        self.newTodoText("");
    }
}

var HeaderViewModel = function(dataModel){
    var self = this;

    this.count = ko.computed(function(){
        return dataModel.items().length;
    });

    this.showUnlockButton = ko.computed(function(){
        return dataModel.locked();
    })

    this.showLockButton = ko.computed(function(){
        return !dataModel.locked();
    })

    this.lock = function(){
        dataModel.locked(true);
    }
    this.unlock = function(){
        dataModel.locked(false);
    }
}


var dataModel = new DataModel();

$("#header").each(function(){
	var headerViewModel = new HeaderViewModel(dataModel);
    ko.applyBindings(headerViewModel, this);
});

$("#list").each(function(){
    var listViewModel = new ListViewModel(dataModel);
    ko.applyBindings(listViewModel, this);
});

And the markup:

<div class="container">
    <div id="header">
        <h2>Header Area</h2>
        Count: <span data-bind="text: count"></span>
        <button class="btn" data-bind="visible: showLockButton, click: lock">Lock</button>
        <button class="btn" data-bind="visible: showUnlockButton, click: unlock">Unlock</button>
    </div>
    <div id="list">
        <h2>List Area</h2>
        <ul data-bind="foreach: items">
            <li data-bind="text: $data"></li>
        </ul>
        <!-- ko ifnot: locked --> 
        <input type="text" data-bind="value: newTodoText"></input>
        <button class="btn" data-bind="click: addNew">Add</button>
        <!-- /ko -->
    </div>
</div>
Andrew Browne
Posted by: Andrew Browne  
Last revised: 20 Feb, 2013 12:54 AM
 

Comments

No comments yet. Be the first!

blog comments powered by Disqus