Simple (yet brittle) two-way data binding

Friday, 9th January 2015

Binding data between your model and view is the big thing at the moment. In this article we will take a look at a very simple implementation using getters and setters on object properties by leveraging Object.defineProperty. I warn you, it will be brittle, and we will only be looking at implementing support for binding data to an elements textContent and using inputs which derive from type="text" to save new states. But it would be a good jumping off point to look into more complex implementations.

We want to be instantiating our model with plain object literals and then be changing values as you normally would on an object literal, by assigning new values to a property. Let's start of by defining our initial model object.

var modelObj = {
    firstName: 'Larry',
    lastName: 'McGovern',
    email: 'larry@mail.com'
};

The next step is to build our Model constructor, this is a function that will take our defined object, iterate over it's properties and return an object containing the same values. However, these properties will have getters and setters defined, allowing us to make any required changes to the DOM each time one of these properties is set. First off, let's just copy our properties in to the new object.

function Model(data) {
    /* Instatiate an empty `model` object. */
    var model = {};

    /* Iterate over the keys of the supplied `data` object. */
    Object.keys(data).forEach(function(key) {
        /* Store our value inside the `forEach` closure. */
        var value = data[key];
        /* Set our model objects property value to the same value. */
        model[key] = value;
    });

    /* Return our new model object. */
    return model;
}

Well currently this does nothing, it just copies the data into a new object. Now it's time to start using Object.defineProperty. By defining properties on our object we gain more control because we can provide a descriptor object. This descriptor object can dictate whether the property is:

  • enumerable: In laymans terms, whether this property will show up in a for..in loop.
  • configurable: Whether once defined this properties descriptor can be changed.

However, the ones we are interested in are the get and set accessor descriptors. By defining these two functions we gain control of what happens when a property is retrieved or changed.

/* Iterate over the keys of the supplied `data` object. */
Object.keys(data).forEach(function(key) {
    /* Store our value inside the `forEach` closure. */
    var value = data[key];

    Object.defineProperty(model, key, {
        /* We want our property to appear in `for..in` loops. */
        enumerable: true,
        get: function() {
            /* This doesn't need to do much, only return the `value` from our closure. */
            return value;
        },
        set: function(val) {
            /* Overwrite our closures `value` with the new `val`. */
            value = val;
            /* At this point we can do anything. */
        }
    });

});

We are going to need some way in the DOM to denote a Nodes reliance on a piece of data from our model. Let's say that anything with the attribute bind={key} will have the data bound to it's textContent and any input with the attribute model={key} will bind into the data object. We are going to be playing with the DOM so we need to make a utility function to get our elements into an array.

function selectorToArray(selector) {
    return Array.prototype.slice.call(document.querySelectorAll(selector));
}

Now we have this utility and our nomenclature in place, let's expand on our set function.

set: function(val) {
    /* Overwrite our closures `value` with the new `val`. */
    value = val;
    /* Select all nodes with `bind` and `model` attributes. */
    selectorToArray('[bind=' + key + ']')
        .concat(selectorToArray('[model=' + key + ']'))
        .forEach(function(el) {
            /* If element has `bind` attribute, set it's `textContent`. */
            if (el.getAttribute('bind')) el.textContent = value;
            /* If element has `model` attribute, set it's `value`. */
            if (el.getAttribute('model')) el.value = value;
        });
}

This will now propagate our changes into our DOM Nodes, but what about the other way round. This is where the example becomes a bit more brittle. What we want to do is, on model instatiation bind keyup listeners to all elements that are bound by model to our object.

/* Select our nodes with the attribute `model` defined for our current key. */
selectorToArray('[model='+key+']').forEach(function(el) {
    /* Our handler simply sets our models `key` to the element's value. */
    function handler() {
        model[key] = el.value;
    }
    /* Bind a `keyup` handler so we get live feedback on each key press. */
    el.addEventListener('keyup', handler);
    /* Bind a `change` handler which is fired when the element is blurred. */
    el.addEventListener('change', handler);
});

Now we have our Model constructor complete, lets put it all together.

function Model(data) {
    /* Instatiate an empty `model` object. */
    var model = {};

    /* Iterate over the keys of the supplied `data` object. */
    Object.keys(data).forEach(function(key) {
        /* Store our value inside the `forEach` closure. */
        var value = data[key];

        Object.defineProperty(model, key, {
            /* We want our property to appear in `for..in` loops. */
            enumerable: true,
            get: function() {
                /* This doesn't need to do much, only return the `value` from our closure. */
                return value;
            },
            set: function(val) {
                /* Overwrite our closures `value` with the new `val`. */
                value = val;
                /* Select all nodes with `bind` and `model` attributes. */
                selectorToArray('[bind=' + key + ']')
                    .concat(selectorToArray('[model=' + key + ']'))
                    .forEach(function(el) {
                        /* If element has `bind` attribute, set it's `textContent`. */
                        if (el.getAttribute('bind')) el.textContent = value;
                        /* If element has `model` attribute, set it's `value`. */
                        if (el.getAttribute('model')) el.value = value;
                    });
            }
        });

        /* Set our model objects property value to the same value. */
        model[key] = value;

        /* Add change handlers to inputs on the page. */
        selectorToArray('[model='+key+']').forEach(function(el) {
            /* Our handler simply sets our models `key` to the element's value. */
            function handler() {
                model[key] = el.value;
            }
            /* Bind a `keyup` handler so we get live feedback on each key press. */
            el.addEventListener('keyup', handler);
            /* Bind a `change` handler which is fired when the element is blurred. */
            el.addEventListener('change', handler);
        });
    });

    /* Return our new model object. */
    return model;
}

function selectorToArray(selector) {
    return Array.prototype.slice.call(document.querySelectorAll(selector));
}

At this point, let's also set up a bit of html in our page which we can use to test. As you will see, we use our attribute nomenclature here and provide both an input with a model attribute and a static bind for all properties of our original object literal.

<input type="text" model="firstName"/>
<div bind="firstName"></div>
<input type="text" model="lastName"/>
<div bind="lastName"></div>
<input type="email" model="emailAddress"/>
<div bind="emailAddress"></div>

Finally, let's instatiate our new model with our original object literal.

var model = Model({
    firstName: 'Larry',
    lastName: 'McGovern',
    email: 'larry@mail.com'
});

Now when you load the page, you should see our data bound to the DOM nodes, and you should also be able to change the values in the input fields which will also update the statically bound nodes aswell. Also, try assigning a new value to your model through the console. Simply try: model.firstName = 'John'. You will see that this kind of assignment will also trigger the DOM nodes to be updated.

As you can probably see, this is a rather contrived example and it doesn't take into account DOM mutations, performance, scope/context, data persistence or different types of input fields.

With a bit of work you could implement a MutationObserver which listens for new nodes in the DOM, checks them against the properties of our model and adds relevant listeners to them. You could also quite easily add support for other types of input fields, having specific handlers and accessors for them.

I hope this article is useful to someone.