Delegate Selectors FTW!

So dang. Each time we submit, it adds a new row to the table, but its delete button doesn't work until we refresh. What's going on here?

Well, let's think about it. In RepLogApp, the constructor function is called when we instantiate it. So, inside $(document).ready():

67 lines app/Resources/views/lift/index.html.twig
... lines 1 - 54
{% block javascripts %}
... lines 56 - 59
<script>
$(document).ready(function() {
var $wrapper = $('.js-rep-log-table');
var repLogApp = new RepLogApp($wrapper);
});
</script>
{% endblock %}

That means it's executed after the entire page has loaded.

Then, at that exact moment, our code finds all elements with a js-delete-rep-log class in the HTML, and attaches the listener to each DOM Element:

99 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 7
this.$wrapper.find('.js-delete-rep-log').on(
'click',
this.handleRepLogDelete.bind(this)
);
... lines 12 - 19
};
... lines 21 - 97
})(window, jQuery);

So if we have 10 delete links on the page initially, it attaches this listener to those 10 individual DOM Elements. If we add a new js-delete-rep-log element later, there will be no listener attached to it. So when we click delete, nothing happens! So, what's the fix?

If you're like me, you've probably fixed this in a really crappy way before. Back then, after dynamically adding something to my page, I would manually try to attach whatever listeners it needed. This is SUPER error prone and annoying!

Your New Best Friend: Delegate Selectors

But there's a much, much, much better way. AND, it comes with a fancy name: a delegate selector. Here's the idea, instead of attaching the listener to DOM elements that might be dynamically added to the page later, attach the listener to an element that will always be on the page. In our case, we know that this.$wrapper will always be on the page.

Here's how it looks: instead of saying this.$wrapper.find(), use this.$wrapper.on() to attach the listener to the wrapper:

102 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 7
this.$wrapper.on(
'click',
... line 10
this.handleRepLogDelete.bind(this)
);
... lines 13 - 22
};
... lines 24 - 100
})(window, jQuery);

Then, add an extra second argument, which is the selector for the element that you truly want to react to:

102 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 7
this.$wrapper.on(
'click',
'.js-delete-rep-log',
this.handleRepLogDelete.bind(this)
);
... lines 13 - 22
};
... lines 24 - 100
})(window, jQuery);

That's it! This works exactly the same as before. It just says:

Whenever a click event bubbles up to $wrapper, please check to see if any elements inside of it with a js-delete-rep-log were also clicked. If they were, fire this function! And have a great day!

You know what else! When it calls handleRepLogDelete, the e.currentTarget is still the same as before: it will be the js-delete-rep-log link element. So all our code still works!

Ah, this is sweet! So let's use delegate selectors everywhere. Get rid of the .find() and add the selector as the second argument:

102 lines web/assets/js/RepLogApp.js
... lines 1 - 2
(function(window, $) {
window.RepLogApp = function ($wrapper) {
... lines 5 - 12
this.$wrapper.on(
'click',
'tbody tr',
this.handleRowClick.bind(this)
);
this.$wrapper.on(
'submit',
'.js-new-rep-log-form',
this.handleNewFormSubmit.bind(this)
);
};
... lines 24 - 100
})(window, jQuery);

To make sure this isn't one big elaborate lie, head back and refresh! Add a new rep log to the page... and delete it! It works! And we can also submit the form again without refreshing!

So always use delegate selectors: they just make your life easy. And since we designed our RepLogApp object around a $wrapper element, there was no work to get this rocking.

Leave a comment!

  • 2017-11-15 Diego Aguiar

    Hey Chris

    I had the same problem once and IIRC you can do this:


    this.$wrapper.on(
    'mouseleave mouseenter', // two events separated by a space
    '.exampleClass',
    this.getTheFunction.bind(this)
    );

    Give it a try and let me know if that works ;)

    Cheers!

  • 2017-11-15 Chris

    I was just coding and a question came up:

    When I need a mouseenter and mouseleave event for the same executable do I have to write it two times, such as:


    this.$wrapper.on(
    'mouseenter',
    '.exampleClass',
    this.getTheFunction.bind(this)
    );

    this.$wrapper.on(
    'mouseleave',
    '.exampleClass',
    this.getTheFunction.bind(this)
    );

    Or is there something similar like this:


    $('.exampleClass').on({
    mouseenter: function() {
    ....;
    },
    mouseleave: function() {
    ....;
    }
    });

    Have a nice day :)

  • 2017-02-14 Victor Bocharsky

    Hey Max,

    No worry! Actually, you ask right questions.. and it's cool that we covered it ;)

    Cheers!

  • 2017-02-13 Max

    I think I might put my questions somewhere first, watch the whole series and recheck them afterwards ;) Thanks Victor!

  • 2017-02-13 Victor Bocharsky

    Yo Max,

    Haha, another good question which we covered further: https://knpuniversity.com/s... ;)

    Cheers!

  • 2017-02-12 Max

    Hey! Is it somehow possible at this point to refresh the form at a successful ajax call as well? Otherwise the form error won't be removed... Thx!