Search code examples
knockout.jscomputed-observable

KnockoutJS computed does not compute (or update)


Under certain circumstances a ko.computed will not update even though the ko.observable that it is bound to changes. I would like to know why, what I am doing wrong and what I should be doing instead.

Example

Consider this simple dozen-to-pieces-converter (JSFiddle here).

HTML

<label>Dozen:</label><br>
<input type="number" data-bind="value: dozen">
    <span class="error" data-bind="text: error"></span><br>
<label>Pieces:</label><br>
<input type="number" data-bind="value: pieces"><br>

JavaScript

function ViewModel() {
    var self = this;
    self.error  = ko.observable('');
    self.pieces = ko.observable('');
    self.dozen  = ko.computed({
        read: function() {
            var p = parseInt(self.pieces(), 10);
            if (!p) return '';
            if (p % 12 === 0) return p / 12;
            else return '';
        },
        write: function(value) {
            if (/\D/.test(value)) {
                self.error('Only whole numbers');
            } else {
                self.error('');
                if (value) self.pieces(value * 12);
            }
        }
    });
}

ko.applyBindings(new ViewModel());

What it does

It will display two inputs. One lets you enter dozens (for example 2) and the other will display how many pieces that is (24).

You can also input a number of pieces (for example 36) and the the converter will show how many dozens that is (3).

If you enter something in the pieces box that isn't divisible by 12, the dozens input is cleared indicating that the number is not an even dozen. Likewise, we don't allow the user to enter fractional dozens.

How it works

The dozens input is bound to a KnockoutJS computed observable. It does not hold it's own value (maybe it does under the hood, but this is beyond my knowledge) but is computed from the pieces property, which is a regular observable. To get the dozens, the pieces is divided by 12 and if the result is a whole number, that is returned, otherwise we return an empty string.

The problem

Start the calculator and try typing 1.5 in the dozens input. The calculator will inform you that decimals are not allowed. The pieces field will be left untouched, as it should. However, the dozens input is not cleared to reflect the value of the pieces field.

Then enter a new value in the pieces input, for example 18. This should clear the dozens input (as it is a computed depending only on the pieces property of the view model, and this value has changed), but it does not.

The value 1.5 keeps being displayed in the dozens field even though the read() function of the computed will never return a decimal like that. The observable that the computed is bound to has been updated, but the computed does not compute.

Only when you enter a number divisible by 12 in the pieces field, will the dozens field update.

My questions

  1. Why does the dozens input keep displaying 1.5 even though the return value from the read() function is an empty string? Can I force a new read() inside or after the write() or otherwise force an update of the UI?

  2. I'm assuming that the reason the dozens input doesn't update even after changing the value of the pieces input, is that the result from the read() function will be the empty string twice in a row and that KnockoutJS caches this value internally and sees that it hasn't changed. Again, how do I force the input to update?


Solution

  • The input keeps displaying 1.5 because the UI and the model are out of sync. The model hasn't received any changes because the value is and always was an empty string. When 1.5 is entered the write function aborts without writing any values to the model so the values are still empty as far as the model is concerned. Then when 18 is entered for Pieces again the function returns an empty string which as you guessed isn't registered as change.

    You can force the computed function to update the UI again with this little gem: computed.notifySubscribers()

    write: function(value) {
      if (/\D/.test(value)) {
        self.error('Only whole numbers');
        self.dozen.notifySubscribers(); //<--- here
      } else {
        self.error('');
        if (value) self.pieces(value * 12);
      }
    }