Knock Me Out

Thoughts, ideas, and discussion about Knockout.js

Event Delegation in KnockoutJS

| Comments

Update #2: Check out this post for a new look at this topic and take a look at the plugin here.

Update: In Knockout 2.0, it is now possible to use ko.dataFor and ko.contextFor instead of tmplItem and use this technique with native templates. Documentation here.

Prior to working with Knockout, I had recently grown fond of event delegation in JavaScript where an event listener is attached to a parent container and events from its children bubble up to this handler. I had primarily been using jQuery’s live() or its more precise cousin delegate() to handle this type of interaction in a fairly painless way.

There are several fairly obvious advantages to using event delegation in cases where you would be adding listeners for lots of events (like for every cell in a large grid):

  • New elements added dynamically to the container can be handled without any additional code. Elements can be removed without having to manage unbinding handlers.
  • Less overhead (memory, processing) than wiring up events to every element.
  • Gives you the flexibility to use one handler to respond to events at multiple levels or from different kinds of elements.

At first glance, it seemed like event delegation didn’t quite fit the Knockout model. You certainly could set a handler on a parent element using the click or event binding. This would allow you to respond to actions on the children, but you would have no connection to the actual data that was used to generate the child element. You could also just use something like jQuery’s live() or delegate() functionality, but you would not get the integration with your view model data that you are used to in the bindings.

tmplItem - a hidden gem in jQuery Templates

In Knockout, the majority of the child content is typically rendered through templates, using the jQuery Templates plug-in. One lesser publicized feature of the plug-in is the tmplItem function. For any element generated by a template, this function allows you to get back to the data that was used to render the template.

For example,

1
2
3
4
5
6
<div data-bind="template: { name: 'nameTmpl', data: person }"></div>

<script id="nameTmpl" type="text/html">

     <span id="name">${firstName} ${lastName}</span>
</script>
1
2
3
4
5
6
7
8
9
10
var viewModel = {
    person: {
        firstName: "Bob",
        lastName: "Smith"
    }
}

ko.applyBindings(viewModel);

var theData = $("#name").tmplItem().data;  //this returns our "person"

So, using the tmplItem function, we are actually able to retrieve some context from an element that was rendered by a template. This is the piece that we were missing to connect our delegated event with the model data related to our child element.

One note: if you are using {{each}} syntax, tmpItem() will still return the data that was the context of the entire template. You can use the template binding or the {{tmpl}} tag to help achieve the proper context for your elements.

Are there benefits to doing event delegation in Knockout?

Wiring up events for dynamically created elements is generally not a problem in Knockout, as we are usually generating the content in templates and can easily add our event handlers declaratively. The other benefits of event delegation still stand though, especially in the case that we have a need to wire up a large number of handlers on our page. I can also see one other small advantage: the first time that you wrote a removeItem method, you might have been a little disappointed that you had to use an anonymous function like:

1
<button data-bind="click: function() { viewModel.removeItem($data); }">Delete</button>

Update: Knockout 2.0 eliminates this pain point by automatically passing the data as the first argument.

With event delegation, we could possibly add a binding to a parent element and just call a method off of the view model passing the actual data that was returned through tmplItem as part of the binding.

Creating a custom binding for delegated clicks

Here is what I started with:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ko.bindingHandlers.delegatedClick = {
    init: function(element, valueAccessor, allBindings, viewModel) {
        var callback = valueAccessor();
        //build a function that will call our method and pass the real data to it
        var realValueAccessor = function() {
            return function(event) {
                var element = event.target;
                    //get real context
                    var context = $(event.target).tmplItem().data;
                    return callback.call(viewModel, context, event);
            }
        }

        //call real click binding's init
        ko.bindingHandlers.click.init(element, realValueAccessor, allBindings, viewModel);
    }
};

So, we are just trying to prepare a function that we can pass on to the real click binding. The function uses tmplItem to retrieve the actual data object related to the element that was clicked and then executes the callback that was passed in through the binding. The actual data object is passed to the function. Now, I can put a binding at the table level to remove an item when I click on a “Delete” button in a cell.

1
<table data-bind="template: {'itemsTmpl', foreach: items }, delegatedClick: removeItem"></table>

This works, but removeItem will get called for Any click inside the table. It doesn’t matter if we click the button, another cell, or a header cell, we will always attempt to run the method. This is certainly not what we want. To limit the elements that trigger our actual method, we can pass in a selector to the binding. As we already have a dependency on jQuery through the jQuery Templates plug-in, we can use jQuery’s .is functionality to make sure that we only proceed when there is a match. Now we pass an object containing our function and a selector to the binding:

1
<table data-bind="template: {'itemsTmpl', foreach: items }, delegatedClick: { callback: removeItem, selector: 'a' }"></table>

In our binding handler, we can make a small modification to check if the element matches our selector.

1
2
3
4
5
if ($(element).is(options.selector)) {
    //get real context
    var context = $(event.target).tmplItem().data;
    return options.callback.call(viewModel, context, event);
}

This works properly when calling parent functions that receive the children as a parameter. However, what if you want to actually call a method off of the child object in this manner? I found that based on the way that Knockout does parsing of the bindings, something like this will not work:

1
<table data-bind="delegatedClick: { callback: childFunction, selector: 'td' }"></table>

This will error out before it gets to my binding, unless childFunction actually exists on the data that is the context of the table’s data binding (the view model passed to applyBindings, unless you are in a template). We can still make this work though by passing the function name as a string and reconciling it in the binding.

1
<table data-bind="delegatedClick: { callback: 'childFunction', selector: 'td' }"></table>

For a minute, I was disappointed that it requires passing a string for the callback, but then I came to the realization that really the whole data-bind attribute is just a string anyways.

Now, I can check for this in the binding:

1
2
3
4
5
6
7
//get real context
var context = $(event.target).tmplItem().data;
if (typeof options.callback === "string" && typeof context[options.callback] === "function") {
    return context[options.callback].call(context, event);
}

return options.callback.call(viewModel, context, event);

If the callback supplied in the binding is a string, then we will call the function off of the child object passing just the event to it. If the function is not a string, then we will call it off of the parent object and pass the child object and the event to it. This allows us to decide whether we want to use a method on our view model or a method on our child object.

One limitation that I immediately ran into was that I might want to pass in multiple click handlers that use different selectors. We should be able to pass in a list of handlers to add as an array.

So, my final version looked like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ko.bindingHandlers.delegatedClick = {
    init: function(element, valueAccessor, allBindings, viewModel) {
        var clicks = valueAccessor();
        ko.utils.arrayForEach(clicks, function(options) {
            var realValueAccessor = function() {
                return function(event) {
                    var element = event.target;
                    if ($(element).is(options.selector)) {
                        //get real context
                        var context = $(event.target).tmplItem().data;
                        if (typeof options.callback === "string" && typeof context[options.callback] === "function") {
                            return context[options.callback].call(context, event);
                        }
                        return options.callback.call(viewModel, context, event);
                    }
                }
            }

            //call real click binding's init
            ko.bindingHandlers.click.init(element, realValueAccessor, allBindings, viewModel);
        });
    }
};

and I can bind to multiple click events like:

1
<table data-bind="template: 'rowsTmpl', delegatedClick: [{callback: 'toggleShowDetails', selector: 'td a' }, { callback: deleteRow, selector: 'button' }]"></table>

Generic binding for all events

Finally, it might be nice to respond to other events in this way, like mouseover and mouseout. Based on the existing event binding, here is a more generic version of the delegatedClick binding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//binding to do event delegation for any event
ko.bindingHandlers.delegatedEvent = {
    init: function(element, valueAccessor, allBindings, viewModel) {
        var eventsToHandle = valueAccessor() || {};
        //if a single event was passed, then convert it to an array
        if (!$.isArray(eventsToHandle)) {
            eventsToHandle = [eventsToHandle];
        }
        ko.utils.arrayForEach(eventsToHandle, function(eventOptions) {
            var realCallback = function(event) {
                var element = event.target;
                var options = eventOptions;
                //verify that the element matches our selector
                if ($(element).is(options.selector)) {
                    //get real context
                    var context = $(event.target).tmplItem().data;
                    //if a string was passed for the function, then assume it is a function of the real context
                    if (typeof options.callback === "string" && typeof context[options.callback] === "function") {
                        return context[options.callback].call(context, event);
                    }
                    //if a function was passed, then give it the real context as a param
                    return options.callback.call(viewModel, context, event);
                }
            }

            var realValueAccessor = function() {
                var result = {};
                result[eventOptions.event] = realCallback;
                return result;
            }

            ko.bindingHandlers.event.init(element, realValueAccessor, allBindings, viewModel);
        });
    }
};

We could bind to the mouseover and mouseout events of our header cells like:

1
<thead data-bind="delegatedEvent: [{ event: 'mouseover', callback: setDescription, selector: 'th'}, { event: 'mouseout', callback: clearDescription, selector: 'th'}]">

I can also now rewrite the delegatedClick binding to be a wrapper to this generic binding.

Here is a grid-like sample that shows using event delegation when binding to a function on the view model, binding to a function on a cell’s related object, and binding to the mouseover and mouseout events on the header cells:

Link to full sample on jsFiddle.net:

Link to full sample on jsFiddle.net

In Knockout, event delegation is probably not necessary in most cases. However, I think that there are scenarios where this could be useful and beneficial, especially when dealing with an extremely large number of elements that need their events handled in similar ways.

Comments