Search code examples
knockout.jsknockout-3.0knockout-components

Knockout component with observable object doesn't update data


I have following component:

<template id="fruits-tpl">
    <p>Name: <input data-bind="value: name" /></p>
    <p>Type: <input data-bind="value: color" /></p>
</template>


ko.components.register('fruits', {
    viewModel: function(params) {
        this.name = params.name;
        this.color   = params.color;
    },
    template: { element: 'fruits-tpl' }
});

I'm using this component together with the view model below, where the items in my observable list are of different types and have different properties:

function Fruit(data) {
    this.name = ko.observable(data.name);
    this.color = ko.observable(data.color);
}
function Dessert(data) {
    this.name = ko.observable(data.name);
    this.packaging = ko.observable(data.packaging);
}
function Vm(){
    var data = [{name:"Apples",color:"Yellow"},{name:"Cookies",packaging:"Box"}];
    this.items = ko.observableArray([new Fruit(data[0]),new Dessert(data[1])]);
    this.items.choice = ko.observable(this.items()[0]);
}

This component works very well and the underlying data is updated every time i change the text in my input boxes:

<div data-bind="component: {name: 'fruits', params: items.choice}"></div>

Now, i would like to encapsulate the logic of my observables into the component itself, so i changed the component this way:

ko.components.register('fruits', {
    viewModel: function(params) {
        this.name = ko.observable(params.name);
        this.color   = ko.observable(params.color);
    },
    template: { element: 'fruits-tpl' }
});

... and now i have my observable items.choice with just only data inside:

function Vm(){
    var data = [{name:"Apples",color:"Yellow"},{name:"Cookies",packaging:"Box"}];
    this.items = ko.observableArray(data);
    this.items.choice = ko.observable(this.items()[0]);
}

Why goes the underlying data in the main viewmodel not updated in my second example, though items.choice is still observable? I'm sure i'm missing some concepts, maybe each item in my observable array should be also observable, but i don't have understand if there is a way to make the second example to work.

First example: http://jsfiddle.net/5739ht0q/2/ Second example: http://jsfiddle.net/079tx0nn/


Solution

  • There are several ways to update the data back to the main view model, which I've shown below.

    But first, let's refine the main view model just a little bit.

    function Vm(data) {
      var self = this;
      self.items = ko.observableArray(ko.utils.arrayMap(data, function(item) {
        return ko.observable(item);
      }));
      self.items.choice = ko.observable(0);
      self.items.choice.data = ko.computed(function() {
        return self.items()[self.items.choice()];
      });
    }
    

    Quick and Dirty

    The first quick & dirty way is to pass to the component a function which has been defined inside the main view model:

    <div data-bind="component: {
        name: 'fruits',
        params: {index: items.choice(), data: items.choice.data(), update: items.update}
    }"></div>
    

    Inside the component we can call the function to save the changes:

    self.data = ko.computed(function(){
        params.update(params.index, ko.toJS(self));
    });
    

    The update function in the main view model is now obvious, no need to spend more words about that.

    Using a subscribable

    A second way would be to use a subscribable to establish a communication along view models:

    ko.intramodels = new ko.subscribable();
    

    From the component, send the notification:

    self.data = ko.computed(function(){
      ko.intramodels.notifySubscribers(ko.toJS(self), "updateFruits");
    });
    

    The subscription inside the main view model will receive and save the changes, more or less something like this:

      ko.intramodels.subscribe(function(newValue) {
        self.items.replace(self.items()[self.items().index], newValue);
      }, self, "updateFruits");
    

    Of course this could be done by hand like above, but the great Ryan Niemeyer's post box library would be the optimal choice here: https://github.com/rniemeyer/knockout-postbox.

    I tested both solutions, but unfortunately i had some trouble when i activated the - new in knockout 3.4 - deferred update option: ko.options.deferUpdates = true; as i received the "Maximum call stack size exceeded" error.

    Remove the circular dependency

    Because I do not want to give up and miss this new wonderful performance enhancement of knockout 3.4, and because this error also means more or less,

    Circular dependencies are a design error, please rethink some part of your implementation,

    I changed the view model to keep the dependency tracking chain working in only one direction but by using just one observable for the whole component data:

    ko.components.register('fruits', {
      viewModel: function(params) {
        var self = this;
        self.data = params.peek();
        self.item = {};
        self.item.name = ko.observable(self.data.name);
        self.item.color = ko.observable(self.data.color);
        self.update = ko.computed(function() {
           params(ko.toJS(self.item));
        });
      },
      template: {
        element: 'fruits-tpl'
      }
    });
    

    This is by far more evident with nested components that have all the data handling and the observable creation within them, and the main view model does't have to know anything about what is inside the children and why -- just only pass to and receive back an observable object:

    <div data-bind="component:{name:'fruits',params:items.choice.data}"></div>
    
    <template id="containers-tpl">
      <div data-bind="foreach: containers">
        <p><input data-bind="textInput: quantity"><span data-bind="text: name"></span></p>
      </div>
    </template>
    
    <template id="fruits-tpl">
      <p>Name:<input data-bind="textInput: item.name"></p>
      <p>Color:<input data-bind="textInput: item.color"></p>
      <div data-bind="component:{name:'containers',params:item.containers}"</div>
    </template>
    

    Key points here are:

    • pass an observable to the component, not just only data, and
    • break the dependency chain of the params inside the component.

    Complete Fiddle with nested components: http://jsfiddle.net/jo37q7uL/