Posted on July 4th, 2011
Knockout provides a great way to implement a MVVM (Mode-View-View Model) pattern in JavaScript on the client side and is easy to integrate with an ASP.NET MVC 3 application. Using a prototype application as an example, we will explore how to create a block of notification data and continuously update it from data received via a controller action. The application markup will display the inventory levels of a product and will include a message when the product has reached a reorder level. It will have a single controller responsible for rendering a single view and providing an action method for getting the current inventory data state.
First things first: get Knockout
Knockout is available via NuGet. You can search for "knockout" in the Manage NuGet Packages dialog or you can use the Package Manager Console and run the command Install-Package knockoutjs
. After adding the package we can include a tag in the
_Layout.cshtml
view and have all we need to start using Knockout (version 1.2.1 at the time of this writing).
The View
The first step to working with Knockout is to craft your view model(s) for your UX. Assuming we have a controller with an Index.cshtml
view set up, within a tag in the view file we create a JavaScript object and define fields on it. For fields that you want Knockout to monitor, you set their value to
ko.observable()
. The view model for the prototype application needs fields for "InStock" and "Reserve". The JavaScript for defining this view model looks like:
var inventoryDataViewModel = {
InStock: ko.observable(0),
Reserve: ko.observable(0)
};
This sets up the client side storage structure for the inventory data. The data also needs a field that represents the total available. This can be determined by getting the difference of the "InStock" amount and the "Reserve" amount. Knockout can handle calculating this via a "dependent observable". A new field named "Available" is created after the initialization of the inventoryDataViewModel
object and is assigned a dependent observable that is passed a function to handle the calculation and the view model to act on (more info on dependent observable can be found in the Knockout documentation).
inventoryDataViewModel.Available = ko.dependentObservable(function () {
return this.InStock() - this.Reserve();
}, inventoryDataViewModel);
It is important to note that the ()
are required after the field names in the function. This tells Knockout to evaluate the current values of each.
We can also add a field that is a dependent observable to keep track of a "low stock" state and use that field to change the look of the UX.
inventoryDataViewModel.StockIsLow = ko.dependentObservable(function () {
return this.Available() < 5;
}, inventoryDataViewModel);
This will encapsulate some logic to indicate if the "Available" value has hit a given threshold (in this case, lower than 5 units). With that in place the markup can check that value and react accordingly.
The last step to working with the JavaScript view model is to tell Knockout to apply bindings. Typically this is just a matter of calling the ko.applyBindings()
method and passing in the instance of the view model. However, it is important to note that this will have Knockout observe the entire DOM. Knockout is capable of working with multiple view models, but to do so you need to pass in a second argument to the ko.applyBindings()
method defining what DOM element to monitor. We will use this approach in our prototype because it is certainly possible that we may want to reuse this notification block across multiple pages (a product details page, a category page that has that product, etc). The JavaScript code to apply the bindings looks like:
ko.applyBindings(inventoryDataViewModel, $("#InventorySummary")[0]);
The second argument is using jQuery to select a DOM element with an id
attribute set to "InventorySummary". The HTML for the inventory summary:
- In Stock:
- Reserve:
- Total Available:
Order more widgets!
The list items contain span
tags that will display the data from the Knockout view model. The data-bind
attribute on the "In Stock", "Reserve" and "Available" elements tell Knockout to set the "text" in the element to the value of the view model InStock
, Reserve
and Available
fields. The call to ko.applyBindings()
triggered Knockout to wire up the bindings and thus understand that the keys "InStock", "Reserve" and "Available" are referring to the fields on the inventoryDataViewModel
object.
Multiple bindings can be added to the data-bind
attribute by separating each with a comma. For the "Available" display we are including the "css" binding as well. If the inventory level has reached a "low stock" state then we tell Knockout to apply the ImportantMessage
css class to the element (more info can be found in the css binding section of the Knockout documentation).
The div
element that has the "Order more widgets!" message implements a similar data binding, but it is checking the opposite value of the StockIsLow
field and adding/removing a css class named Hidden
(that contains a css definition display:none;
) accordingly. In order to do a logic check in the data-bind
attribute you need to include the ()
on the view model field name in order to let Knockout know that it needs to evaluate the current value.
Our prototype needs to handle updating this data in "real-time". This can be accomplished by using the JavaScript setTimeout
method to continuously poll the data from a MVC controller action. A function can be created to make a jQuery call to the $.get()
method and upon completion it can update the Knockout view model and call the setTimeout
method.
function PollInventoryData() {
$.get('/Home/InventoryData', function (data) {
inventoryDataViewModel.InStock(data.InStock);
inventoryDataViewModel.Reserve(data.Reserve);
setTimeout("PollInventoryData()", 5000);
});
}
The setTimeout
method tells the client to call the given method (in this case, itself) after a period of X milliseconds. The logic in this method will make the asynchronous call to the controller action, and when it is completed it will "queue" up another call to itself. Thus, while the user remains on this page, the inventory data will continuously get polled and our UX will get updated each time. The final thing we need to do in the View code is to make the initial call to the PollInventoryData()
method. We will do this once the document is ready via the jQuery check:
$(function () {
PollInventoryData();
});
The Index.cshtml
view file:
@{ ViewBag.Title = "Product: Widget"; }
Product: Widget
- In Stock:
- Reserve:
- Total Available:
Order more widgets!
The Site.css
file:
.Hidden { display:none; }
.ImportantMessage { color:#ff0000; }
The Model Classes
A basic model class named InventoryData
is created in MVC to handle the inventory data structure. It only needs to contain properties for the "In Stock" and "Reserve" values. All other data is handled via the Knockout code we put in the View.
namespace Website.Models
{
public class InventoryData
{
public int InStock { get; set; }
public int Reserve { get; set; }
}
}
To simulate a data management layer we will use a similar method that was used in an earlier post, "Build a dialog form using jQuery UI in MVC 3". We can create a class named InventoryManager
with a method to get inventory data and create initial data. This class will store data in the runtime cache to give us a rudimentary way of simulating data storage and persistence in our prototype. You would want to refactor this to work with whatever data storage means you are currently or planning to implement.
using System;
using System.Web;
namespace Website.Models
{
public class InventoryManager
{
public InventoryData GetInventoryData()
{
if(HttpRuntime.Cache["InventoryData"] == null)
return this.CreateInitialData();
var item = (InventoryData)HttpRuntime.Cache["InventoryData"];
item.Reserve += this.generateRandomReserveAmount();
return item;
}
public InventoryData CreateInitialData()
{
var item = new InventoryData { InStock = 20, Reserve = 0 };
HttpRuntime.Cache["InventoryData"] = item;
return item;
}
private int generateRandomReserveAmount()
{
var random = new Random();
return random.Next(1, 6);
}
}
}
The private generateRandomReserveAmount
is used to simulate someone placing an order for the product, thus putting X amount in "Reserve". This code is called in the GetInventoryData
method so that each call to that method will trigger the simulation. Again, the code in this class is purely there to get our UX functioning in a sample without the overhead of creating a full blown data layer.
The Controller
Using a general HomeController
for the prototype, we can add an action method for rendering the view and one for getting the inventory data. The Index
method will just return the view. The InventoryData
method will instantiate an instance of an InventoryManager
class, call the GetInventoryData
method to get an instance of an InventoryData
model, and return the model instance as JSON.
using System.Web.Mvc;
using Website.Models;
namespace Website.Controllers
{
public class HomeController : Controller
{
public static int CurrentInning;
public ActionResult Index()
{
return View();
}
[OutputCache(Duration = 0)]
public JsonResult InventoryData()
{
var manager = new InventoryManager();
var model = manager.GetInventoryData();
return Json(model, JsonRequestBehavior.AllowGet);
}
}
}
Since the jQuery call will cache the call to the content in Internet Explorer by default (a setting in jQuery), we need to decorate the InventoryData
action method with the OutputCache
attribute and a duration of 0. This will send the no-cache flag back in the header of the content to the browser and jQuery will pick that up and not cache the call, thus keeping our data fresh each time the controller method is polled for data.
Lights, camera, action
Everything is in place and the code is ready to run. Doing an F5 will result in the inventory levels getting rendered and the "order more" message hidden. Approximately every 5 seconds the InventoryData
controller action will get hit and the UX will get updated with new "Reserve" and "Available" amounts. Once the "Available" amount falls below 5 the font color of the "Available" will change to red and the "order more" message becomes visible. If you don't feel like waiting it out you can refresh the page over and over, which will trigger the initial call to the controller action and move you through the simulation faster.
Extra credit
Lets take a look at another feature of Knockout and in the process provide us a means of resetting our data (instead of having to either kill Cassini or IIS Express to clear the cache from our data storage simulation code). We can create a button to reset levels and use Knockout to bind a click event to it. Start by adding a button
element within the "InventorySummary" element and set the data-bind
attribute to use the "click" binding:
- In Stock:
- Reserve:
- Total Available:
Order more widgets!
This tells Knockout to monitor the click event of the element and call the function that is bound to the ResetLevels
field of the view model. To add that logic, all we need to do is define a new field in the JavaScript view model instantiation and set its value to a function:
var inventoryDataViewModel = {
InStock: ko.observable(0),
Reserve: ko.observable(0),
ResetLevels: function () {
$.post('/Home/ResetInventory', function (data) {
inventoryDataViewModel.InStock(data.InStock);
inventoryDataViewModel.Reserve(data.Reserve);
});
}
};
The function will call the jQuery $.post()
method to hit a controller action named ResetInventory
and upon completion it will update the view model fields. By updating the view model fields here we can keep the UX data current without having to interrupt the current polling that is taking place via our existing logic.
The only other thing we need to do is add the controller action method to the HomeController
:
public JsonResult ResetInventory()
{
var manager = new InventoryManager();
var model = manager.CreateInitialData();
return Json(model);
}
Since the InventoryManager
class already has the method CreateInitialData
, we simply call that and use the returned value as the model for the JsonResult
. Now we have a way to manually reset the data while the page is up.
Knockout has a ton of potential uses on the client side for gaining control over your application code structure and maintainability. It also helps reduce the amount of code needed to handle changes in data and user events which is a welcome relief, especially as your application grows and you realize how much jQuery you have been writing to wire up and pull strings to make your interface dance!
Download the code
No new comments are allowed on this post.
Discussion
Simon
This is a very well written article and I enjoyed it. However this isn't actually "real time" it is just close to real time. If you coupled this with a comet implementation then you could eliminate the need to do polling of the server.
Justin Schwartzenberger
Michael Flynn
I just did what you with knockout.js did but actually implemented long polling as the solution instead of setTimeout. Look into AsyncController to accomplish this. You can view the long polling on grassrootshoops.com and Firebug.
Simon Timms
Take a look at https://github.com/nmosafi/aspcomet which is a pretty good solution for .net. We put out state updates via nservicebus and have the asp.net site act as a listener for these messages. They they filter the messags and send them on to interested clients. If web sockets ever gain better acceptance, and they will, then that is a way better solution. As it stands our site defaults to long polling which isn't ideal.
Felix
Justin, Excellent article. I have a few comments, but those are just minor gripes :)
You have css:{ ImportantMessage: StockIsLow } (in two places). I think it's just a typo and should be css:{ ImportantMessage: StockIsLow() }, as is in another place.
Also, you have second parameter in applyBindings - $("#InventorySummary")[0], but you never describe why you need it there. After all, it works just fine without it, and examples on knockout page don't have second param.
Last, you simplified your example by putting JavaScript at the end of the file. Nowadays we typically put all of JS at the top and wrap it into $function({ ... }). However, if I do that, there is a problem with setTimeout. I would recommend to put PollInventoryData inside JQuery block and then change seTimeout as follows:
Felix
Sorry, two more. You run setTimeout(), but never clear it - in fact never assign it to a variable, that would allow you to clear it. So, even when stock is low and the page shows "order more widgets", the code pings the server for update, and stock keeps going down...
And really minor. It would be more intuitive to show InStock going down, rather than Reserve for some reason going up. At least, that's what my retailer wife tells me ;)
Justin Schwartzenberger
css:{ ImportantMessage: StockIsLow }
for the total available doesn't need the()
because the data inStockIsLow
is abool
. Thecss: { Hidden: !StockIsLow() }
in the message uses the()
because there is an expression there (the check for not "stock is low"). These two are different to illustrate how you handle each case in Knockout. If you have a variable that resolves to a true/false then you don't need the()
. Otherwise you need to call the function on that variable if you want to use it in an equation in thedata-bind
attribute. Knockout docs section for more info.ko.applyBindings(inventoryDataViewModel, $("#InventorySummary")[0]);
in the article explains the second param.setTimeout
is a better approach. Good call.Justin Schwartzenberger
PollInventoryData
function to make that decision, then not call thesetTimeout
method. The recursive call toPollInventoryData
should be the thing that is causing the repeat polling. ThesetTimeout
should simply be delaying the call to it for a brief moment.Justin Schwartzenberger
Justin Schwartzenberger
Felix
Sorry, I missed the stuff before the code; was looking only at the statement after it :(
Got to go with what my wife says... :D
Justin Schwartzenberger
Anne
Nice work Justin.
Billy Braga
This ain't realtime !!! Like the first guy said...
Salvatore Di Fazio
Thank you for your tutorial :)