2
votes

There have been a lot of questions posted about using checkboxes in a foreach loop in Knockout, but I haven't seen an answer to why initalizing the checked binding works in some situations and not others.

With this simple viewmodel:

viewModel = function (obj) {
    this.AllPossibleThings = ko.observableArray(
    [
        { ID: "1", Value: 'Thing1' }, 
        { ID: "2", Value: 'Thing2' }, 
        { ID: "3", Value: 'Thing3' }, 
        { ID: "4", Value: 'Thing4' }
    ]);
    this.selectedThings = ko.observableArray(["2","3"]);
};
$(ko.applyBindings(new viewModel()));

Putting these in a foreach template works as you'd expect. It shows 4 checkboxes, with 2 and 3 pre-checked:

<table>
    <tbody data-bind="template: { name: 'ThingTmpl', foreach: AllPossibleThings }">
    </tbody>
</table>
<script id="ThingTmpl" type="text/html">
        <tr>
            <td><input type="checkbox" 
                      data-bind="attr: {value: ID}, checked: $root.selectedThings" /></td>
            <td><span data-bind="text: Value"></span></td>
        </tr>
</script>

Doing what should be the equivalent without a template doesn't work the same:

<table>
    <tbody data-bind="foreach: AllPossibleThings">
        <tr>
            <td><input type="checkbox" 
                      data-bind="checked: $root.selectedThings, 
                                 attr: {value: ID}" /></td>
            <td><span data-bind="text: Value"></span></td>
        </tr>
    </tbody>
</table>

The non-template one shows 4 checkboxes, but with none pre-checked. BUT, when you click, say, the 1st checkbox then 2 and 3 get checked. It's like it got bound before the "selectedThings" array got created and the change was never observed.

Fiddle to demonstrate with both: http://jsfiddle.net/jturnage/j763w/

Anybody know why the template foreach works here, but the regular foreach binding does not?

1
Any reason why you're not using the value binding instead? - Jeff Mercado
You use the value binding for elements such as text input, select, and textarea. You use checked for radio and checkbox inputs. - Jason Turnage
Oh right, what was I thinking. - Jeff Mercado

1 Answers

2
votes

The problem is the order of your bindings and your browser.

It is my understanding that the order in which you iterate over the property names of an object is not well-defined and cannot be relied on. It may or may not be iterated in the order you have declared them. Knockout internally turns this binding to an object and iterates over the properties of the object. In this case, it happens to be in the order it was declared.

Your binding on the checkbox in the template is like this:

data-bind="attr: {value: ID}, checked: $root.selectedThings"

While the binding not in your template is like this:

data-bind="checked: $root.selectedThings, attr: {value: ID}"

The reason why the binding isn't working for you is because the attr binding hasn't been applied yet. So the checkboxes don't have their values set to the corresponding ID. When the checked binding updates, it is searching the selectedThings for non-existant values. You'll see it will work if you change the order of the binding so attr is applied first.


A better and safer way to deal with this IMO would test yourself whether it should be checked or not, rather than depending on this unstable behavior. Add a function which tests if a particular item is selected. Then it won't matter what order these bindings are fired.

viewModel = function (obj) {
    // ...

    this.isSelected = function (thing) {
        return this.selectedThings.indexOf(thing.ID) !== -1;
    };
};
<td><input type="checkbox" 
           data-bind="checked: $root.isSelected($data), attr: {value: ID}" />
</td>

Of course, you'll have to make adjustments if you want to keep the selectedThings synchronized. You can get that with some clever use of a computed observable.

this.isSelected = function (thing) {
    return ko.computed({
        read: function () {
            return this.selectedThings.indexOf(thing.ID) !== -1;
        },
        write: function (newValue) {
            var index = this.selectedThings.indexOf(thing.ID);
            if (newValue) {
                // checked
                if (index === -1)
                    this.selectedThings.push(thing.ID);
            } else {
                // unchecked
                if (index !== -1)
                    this.selectedThings.remove(thing.ID);
            }
        }
    }, this);
};

Updated fiddle