Greg
Greg

Reputation: 11542

backbone.js click and blur events

I'm having some trouble with blur and click events in backbone. I have a view (code below) that creates a little search entry div with a button. I pop open this div and put focus on the entry field. If someone clicks off (blur) I notify a parent view to close this one. If they click on the button I'll initiate a search.

The blur behavior works fine, however when I click on the button I also get a blur event and can't get the click event. Have I got this structured right?

BTW, some other posts have suggested things like adding timers to the div in case its being closed before the click event fires. I can comment out the close completely and still only get the blur event. Do these only fire one at a time on some kind of first-com-first-served basis?

PB_SearchEntryView = Backbone.View.extend({
    template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
    events: {
        "click button": "find",
        "blur #part": "close"
    },
    initialize: function(args) {
        this.dad = args.dad;
    },
    render: function(){
        $(this.el).html(this.template());
        return this;
    },
    close: function(event){ this.dad.close(); },
    find: function() {
        alert("Find!");
    }
});

Upvotes: 2

Views: 12967

Answers (2)

Julian Gonggrijp
Julian Gonggrijp

Reputation: 4366

Despite the question being 11 years old, it is still possible to run into this situation with current versions of Backbone, Underscore and jQuery, and the solution is also still the same.

Let us start with a reproduction of the problem in a runnable snippet. Below, I copied the code from the question and added some mock code. To reproduce, please click "Run code snippet", then manually focus the text input by clicking inside it, and then click the button. You will see that the input field and the button disappear again and the alert('Find!') line does not run:

var PB_SearchEntryView = Backbone.View.extend({
    template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
    events: {
        "click button": "find",
        "blur #part": "close"
    },
    initialize: function(args) {
        this.dad = args.dad;
    },
    render: function(){
        $(this.el).html(this.template());
        return this;
    },
    close: function(event){
        this.dad.close();
    },
    find: function() {
        alert("Find!");
    }
});

var MockDadView = Backbone.View.extend({
    initialize: function() {
        this.search = new PB_SearchEntryView({
            dad: this
        }).render();
    },
    render: function() {
        this.$el.append(this.search.el);
        return this;
    },
    close: function() {
        this.search.remove();
    }
});

var view = new MockDadView().render();
view.$el.appendTo(document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/underscore-umd-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/backbone-min.js"></script>

The reason is as follows. An action by the user will often trigger many different types of events. In this case, clicking the button may be expected to trigger all of the following events:

  1. blur on the <input> element.
  2. change on the <input> element (only if the user was typing into it just before clicking the button; in other words, if the blur was preceded by an input event).
  3. mousedown on the <button> element.
  4. mouseup on the <button> element.
  5. click on the <button> element.
  6. submit on a parent <form> element, if there is one (because we did not set the the type attribute of the <button> and it defaults to submit).

The above order is standardized, because this order is most useful for implementing user-friendly interactive forms. In particular, blur and change on the <input> trigger before any events on the <button>, because those events are potentially good opportunities to validate the user's input prior to taking further form processing steps. Likewise, the <button> events trigger before the submit of the entire <form>, because you might want to do final whole-form validation or other preprocessing before finally actually submitting the form.

Handlers for these events are immediately invoked by the browser, and the call stack is allowed to unwind completely before the next user event is triggered. This is JavaScript's famous event loop at play. Hence, the following things happen when we click the button in the problematic snippet above:

  1. The blur event fires.
  2. The close method of our PB_SearchEntryView instance is invoked.
  3. The close method of our MockDadView is invoked.
  4. The remove method of our PB_SearchEntryView instance is invoked.
  5. The HTML element of our PB_SearchEntryView instance is removed from the DOM and its event bindings are deleted (this is the default behavior of the remove prototype method of Backbone.View).
  6. The methods invoked above return in reverse order.
  7. The click event on the <button> is never triggered, because the element has been removed from the DOM in step 5.
  8. (Even if the click event was triggered in step 7, the find method of our PB_SearchEntryView instance would still not be invoked, because the event binding was undone in step 5.)

Hence, if we still want the find method to be invoked after the blur has been handled, we need to prevent the remove method from being invoked in the meanwhile. A straightforward and valid way to do this, is to delay the call to this.dad.close. A suitable delay is about 50 milliseconds; this is long enough to ensure that the click event of the <button> is triggered first and short enough that the user does not notice it. We can even cancel the delayed call in order to keep the search view open when the button is clicked. In the snippet below, I changed the close and find methods to illustrate how this can be done and added comments to explain the mechanics:

var PB_SearchEntryView = Backbone.View.extend({
    template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
    events: {
        "click button": "find",
        "blur #part": "close"
    },
    initialize: function(args) {
        this.dad = args.dad;
    },
    render: function(){
        $(this.el).html(this.template());
        return this;
    },
    close: function(event){
        // Short way to refer to this.dad.
        var dad = this.dad;
        // The method of this.dad we want to be
        // invoked later. When saving it to a
        // variable, we need to bind it so that
        // the `this` variable points to this.dad
        // when it is invoked.
        var askDadToClose = dad.close.bind(dad);
        // Invoke dad's close method with a 50 ms
        // delay. Save a handle that enables us
        // to cancel the call in the find method.
        this.willClose = setTimeout(askDadToClose, 50);
    },
    find: function() {
        alert("Find!");
        // If you still want the search view to
        // close after performing the search,
        // remove the `if` block below.
        if (this.willClose != null) {
            clearTimeout(this.willClose);
            delete this.willClose;
        }
    }
});

var MockDadView = Backbone.View.extend({
    initialize: function() {
        this.search = new PB_SearchEntryView({
            dad: this
        }).render();
    },
    render: function() {
        this.$el.append(this.search.el);
        return this;
    },
    close: function() {
        this.search.remove();
    }
});

var view = new MockDadView().render();
view.$el.appendTo(document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/underscore-umd-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/backbone-min.js"></script>

Bonus material

The question also asked whether the code was structured right. Below is a third version of the snippet, in which I made three changes that I think would be improvements:

  1. The child view (PB_SearchEntryView) no longer knows about its parent view. Instead of invoking a method on the parent, it simply triggers an event. It is up to other components of the application to listen for that event and handle it. This reduces coupling of components and generally makes applications easier to test and maintain. (For loose coupling of long-distance communication, I recommend using backbone.radio.)
  2. Instead of listening for the click event on a <button>, listen for a submit event on a <form>. This is more semantic and appropriate when submitting a form is exactly what we are doing. This also enables the user to trigger the same event handler by pressing the return key while typing into the <input> element. This does require that the <button type=submit> is inside a <form> element, which is why we set the tagName of the view to form.
  3. Put the views in charge of rendering themselves. Generally, the view itself knows best when to change its internal HTML structure and generally, this is both when it is first created and whenever its model or collection changes (if any). Making external components responsibe for rendering is usually an antipattern, unless rendering is really expensive (for example if rendering a map or a complex visualization).

var PB_SearchEntryView = Backbone.View.extend({
    // This is a form, so it can be submitted.
    tagName: 'form',
    template: _.template("<div id='searchEntry' class='searchEntry'><input id='part'></input><button id='findit'>Search</button></div>"),
    events: {
        // Handle submit rather than a button click.
        "submit": "find",
        "blur #part": "close"
    },
    initialize: function(args) {
        // self-render on initialize
        this.render();
    },
    render: function(){
        this.$el.html(this.template());
        return this;
    },
    close: function(event){
        // Like before, we save a method for later,
        // but this time it is our own trigger
        // method and we bind two additional
        // arguments. This view does not need to
        // know what happens with its 'close' event.
        var triggerClose = this.trigger.bind(this, 'close', this);
        // Delay as before.
        this.willClose = setTimeout(triggerClose, 50);
    },
    find: function(event) {
        // Since this is now a proper submit
        // handler, we need to prevent the default
        // behavior of reloading the page.
        event.preventDefault();
        alert("Find!");
        if (this.willClose != null) {
            clearTimeout(this.willClose);
            delete this.willClose;
        }
    }
});

var MockDadView = Backbone.View.extend({
    initialize: function() {
        // This view owns an instance of the above
        // view and knows what to do with its
        // 'close' event.
        this.search = new PB_SearchEntryView();
        this.search.on('close', this.close, this);
        // self-render on initialize
        this.render();
    },
    render: function() {
        this.$el.append(this.search.el);
        return this;
    },
    close: function() {
        this.search.remove();
    }
});

var view = new MockDadView();
view.$el.appendTo(document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/underscore-umd-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/backbone-min.js"></script>

There is a slight catch with using the above snippet, at least in Safari: if I submit the form by pretting the return key, the find method is still invoked, but the form is also removed. I suspect Safari is triggering a new blur event after closing the alert in this case. Solving that would be enough material for another question!

Upvotes: 0

Sergei Golos
Sergei Golos

Reputation: 4350

I am not sure what the problem was, but here is the jsbin code.

Upvotes: 0

Related Questions