Knockout is a nice JavaScript library for making values that automatically update when any of their “dependencies” update. Those dependencies can form an arbitrary directed acyclic graph. Many people seem to think of it as “yet another” templating library, but the core idea which is useful far beyond “templating” is the notion of observable values. One nice aspect is that it is a library and not a framework so you can use it as little or as much as you want and you can integrate it with other libraries and frameworks.
At any rate, this article is more geared toward those who have already decided on using Knockout or a library (in any language) offering similar capabilities. I strongly suspect the issues and solutions I’ll discuss apply to all similar sorts of libraries. While I’ll focus on one particular example, the ideas behind it apply generally. This example, admittedly, is one that almost anyone will implement, and in my experience will do it incorrectly the first time and won’t realize the problem until later.
When doing any front-end work, before long there will be a requirement to support “multi-select” of something. Of course, you want the standard select/deselect all functionality and for it to work correctly, and of course you want to do something with the items you’ve selected. Here’s a very simple example:
Item | |
---|---|
Here, the number selected is an overly simple example of using the selected items. More realistically, the selected items will trigger other items to show up and/or trigger AJAX requests to update the data or populate other data. The HTML for this example is completely straightforward.
<div id="#badExample">
<span data-bind="text: $data.numberSelected()"></span>
Number selected: <table>
<tr><th><input type="checkbox" data-bind="checked: $data.allSelected"/></th><th>Item</th></tr>
<!-- ko foreach: { data: $data.items(), as: '$item' } -->
<tr><td><input type="checkbox" data-bind="checked: $data.selected"/></td><td data-bind="text: 'Item number: '+$data.body"></td></tr>
<!-- /ko -->
<tr><td><button data-bind="click: function() { $data.add(); }">Add</button></td></tr>
</table>
</div>
The way nearly everyone (including me) first thinks to implement this is by adding a selected
observable to each item and then having allSelected
depend on all of the selected
observables. Since we also want to write to allSelected
to change the state of the selected
observables we
use a writable computed observable. This computed observable will loop through all
the items and check to see if they are all set to determine it’s state. When it is updated, it will loop through all the selected
observables
and set them to the appropriate state. Here’s the full code listing.
var badViewModel = {
counter: 0,
items: ko.observableArray()
;
}
.allSelected = ko.computed({
badViewModelread: function() {
var items = badViewModel.items();
var allSelected = true;
for(var i = 0; i < items.length; i++) { // Need to make sure we depend on each item, so don't break out of loop early
= allSelected && items[i].selected();
allSelected
}return allSelected;
,
}write: function(newValue) {
var items = badViewModel.items();
for(var i = 0; i < items.length; i++) {
.selected(newValue);
items[i]
}
};
})
.numberSelected = ko.computed(function() {
badViewModelvar count = 0;
var items = badViewModel.items();
for(var i = 0; i < items.length; i++) {
if(items[i].selected()) count++;
}return count;
;
})
.add = function() {
badViewModel.items.push({
badViewModelbody: badViewModel.counter++,
selected: ko.observable(false)
;
});
}
.applyBindings(badViewModel, document.getElementById('#badExample')); ko
This should be relatively straightforward, and it works, so what’s the problem? The problem can be seen in numberSelected
(and it also comes up
with allSelected
which I’ll get to momentarily). numberSelected
depends on each selected
observable and so it will be fired each time each
one updates. That means if you have 100 items, and you use the select all checkbox, numberSelected
will be called 100 times. For this example,
that doesn’t really matter. For a more realistic example than numberSelected
, this may mean rendering one, then two, then three, … then 100 HTML
fragments or making 100 AJAX requests. In fact, this same behavior is present in allSelected
. When it is written, as it’s writing to the selected
observables, it is also triggering itself.
So the problem is updating allSelected
or numberSelected
can’t be done all at once, or to use database terminology, it can’t be updated atomically.
One possible solution in newer versions of Knockout is to use deferredUpdates
or, what I did back in the much earlier versions of Knockout, abuse the
rate limiting features. The problem with this solution is that it makes updates asynchronous. If you’ve written your code to not care whether it was
called synchronously or asynchronously, then this will work fine. If you haven’t, doing this throws you into a world of shared state concurrency and
race conditions. In this case, this solution is far worse than the disease.
So, what’s the alternative? We want to update all selected items atomically; we can atomically update a single observable; so we’ll put all selected items into a single observable. Now an item determines if it is selected by checking whether it is in the collection of selected items. More abstractly, we make our observables more coarse-grained, and we have a bunch of small computed observables depend on a large observable instead of a large computed observable depending on a bunch of small observables as we had in the previous code. Here’s an example using the exact same HTML and presenting the same overt behavior.
Item | |
---|---|
And here’s the code behind this second example:
var goodViewModel = {
counter: 0,
selectedItems: ko.observableArray(),
items: ko.observableArray()
;
}
.allSelected = ko.computed({
goodViewModelread: function() {
return goodViewModel.items().length === goodViewModel.selectedItems().length;
,
}write: function(newValue) {
if(newValue) {
.selectedItems(goodViewModel.items().slice(0)); // Need a copy!
goodViewModelelse {
} .selectedItems.removeAll();
goodViewModel
}
};
})
.numberSelected = ko.computed(function() {
goodViewModelreturn goodViewModel.selectedItems().length;
;
})
.add = function() {
goodViewModelvar item = { body: goodViewModel.counter++ }
.selected = ko.computed({
itemread: function() {
return goodViewModel.selectedItems.indexOf(item) > -1;
,
}write: function(newValue) {
if(newValue) {
.selectedItems.push(item);
goodViewModelelse {
} .selectedItems.remove(item);
goodViewModel
}
};
}).items.push(item);
goodViewModel;
}
.applyBindings(goodViewModel, document.getElementById('#goodExample')); ko
One thing to note is that setting allSelected
and numberSelected
are now both simple operations. A write to an observable triggers a constant
number of writes to other observables. In fact, there are only two (non-computed) observables. On the other hand, reading the selected
observable
is more expensive. Toggling all items has quadratic complexity. In fact, it had quadratic complexity before due to the feedback. However, unlike the
previous code, this also has quadratic complexity when any individual item is toggled. Unlike the previous code, though, this is simply due to a
poor choice of data structure. Equipping each item with an “ID” field and using an object as a hash map would reduce the complexity to linear. In
practice, for this sort of scenario, it tends not to make a big difference. Also, Knockout won’t trigger dependents if the value doesn’t change, so
there’s no risk of the extra work propagating into still more extra work. Nevertheless, while I endorse this solution for this particular problem,
in general making finer grained observables can help limit the scope of changes so unnecessary work isn’t done.
Still, the real concern and benefit of this latter approach isn’t the asymptotic complexity of the operations, but the atomicity of the operations.
In the second solution, every update is atomic. There are no intermediate states on the way to a final state. This means that dependents, represented
by numberSelected
but which are realistically much more complicated, don’t get triggered excessively and don’t need to “compensate” for unintended intermediate
values.
We could take the coarse-graining to its logical conclusion and have the view model for an application be a single observable holding an object representing the entire view model (and containing no observables of its own). Taking this approach actually does have a lot of benefits, albeit there is little reason to use Knockout at that point. Instead this starts to lead to things like Facebook’s Flux pattern and the pattern perhaps most clearly articulated by Cycle JS.