The following is a guest post by Philip Walton (@philwalton). He is going to explain why stopping event propagation isn’t something you should do lightly, and probably something you should avoid altogether.
If you’re a front end developer, at some point in your career you’ve probably had to build a popup or dialog that dismissed itself after the user clicked anywhere else on the page. If you searched online to figure out the best way to do this, chances are you came across this Stack Overflow question: How to detect a click outside an element?.
Here’s what the highest rated answer recommends:
$('html').click(function() {
// Hide the menus if visible.
});
$('#menucontainer').click(function(event){
event.stopPropagation();
});
In case it’s not clear what this code is doing, here’s a quick rundown: If a click event propagates to the <html>
element, hide the menus. If the click event originated from inside #menucontainer
, stop that event so it will never reach the <html>
element, thus only clicks outside of #menucontainer
will hide the menus.
The above code is simple, elegant, and clever all at the same time. Yet, unfortunately, it’s absolutely terrible advice.
This solution is roughly equivalent to fixing a leaky shower by turning off the water to the bathroom. It works, but it completely ignores the possibility that any other code on the page might need to know about that event.
Still, it’s the most upvoted answer to this question, so people assume it’s sound advice.
What Can Go Wrong?
You might be thinking to yourself: who even writes code like this themselves anymore? I use a well-tested library like Bootstrap, so I don’t need to worry, right?
Unfortunately, no. Stopping event propagation is not just something recommended by bad Stack Overflow answers; it’s also found in some of the most popular libraries in use today.
To prove this, let me show you how easy it is to create a bug by using Bootstrap in a Ruby on Rails app. Rails ships with a JavaScript library called jquery-ujs that allows developers to declaratively add remote AJAX calls to links via the data-remote
attribute.
In the following example, if you open the dropdown and click anywhere else in the frame, the dropdown should close itself. However, if you open the dropdown and then click on “Remote Link”, it doesn’t work.
See the Pen Stop Propagation Demo by Philip Walton (@philipwalton) on CodePen.
This bug happens because the Bootstrap code responsible for closing the dropdown menu is listening for click events on the document. But since jquery-ujs stops event propagation in its data-remote
link handlers, those clicks never reach the document, and thus the Bootstrap code never runs.
The worst part about this bug is that there’s absolutely nothing that Bootstrap (or any other library) can do to prevent it. If you’re dealing with the DOM, you’re always at the mercy of whatever other poorly-written code is running on the page.
The Problem with Events
Like a lot of things in JavaScript, DOM events are global. And as most people know, global variables can lead to messy, coupled code.
Modifying a single, fleeting event might seem harmless at first, but it comes with risks. When you alter the behavior that people expect and that other code depends on, you’re going to have bugs. It’s just a matter of time.
And in my experience, these sorts of bugs are some of the hardest to track down.
Why Do People Stop Event Propagation?
We know there’s bad advice on the Internet promoting the unnecessary use of stopPropagation
, but that isn’t the only reason people do it.
Frequently, developers stop event propagation without even realizing it.
Return false
There’s a lot of confusion around what happens when you return false
from an event handler. Consider the following three cases:
<!-- An inline event handler. -->
<a href="http://google.com" onclick="return false">Google</a>
// A jQuery event handler.
$('a').on('click', function() {
return false;
});
// A native event handler.
var link = document.querySelector('a');
link.addEventListener('click', function() {
return false;
});
These three examples all appear to be doing the exact same thing (just returning false
), but in reality the results are quite different. Here’s what actually happens in each of the above cases:
- Returning
false
from an inline event handler prevents the browser from navigating to the link address, but it doesn’t stop the event from propagating through the DOM. - Returning
false
from a jQuery event handler prevents the browser from navigating to the link address and it stops the event from propagating through the DOM. - Returning
false
from a regular DOM event handler does absolutely nothing.
When you expect something to happen and it doesn’t, it’s confusing, but you usually catch it right away. A much bigger problem is when you expect something to happen and it does but with unanticipated and unnoticed side-effects. That’s where nightmare bugs come from.
In the jQuery example, it’s not at all clear that returning false
would behave any differently than the other two event handlers, but it does. Under the hood, jQuery is actually invoking the following two statements:
event.preventDefault();
event.stopPropagation();
Because of the confusion around return false
, and the fact that it stops event propagation in jQuery handlers, I’d recommend never using it. It’s much better to be explicit with your intentions and call those event methods directly.
Note: If you use jQuery with CoffeeScript (which automatically returns the last expression of a function) make sure you don’t end your event handlers with anything that evaluates to the Boolean false
or you’ll have the same problem.
Performance
Every so often you’ll read some advice (usually written a while ago) that recommends stopping propagation for performance reasons.
Back in the days of IE6 and even older browsers, a complicated DOM could really slow down your site. And since events travel through the entire DOM, the more nodes you had, the slower everything got.
Peter Paul Koch of quirksmode.org recommended this practice in an old article on the subject:
If your document structure is very complex (lots of nested tables and such) you may save system resources by turning off bubbling. The browser has to go through every single ancestor element of the event target to see if it has an event handler. Even if none are found, the search still takes time.
With today’s modern browsers, however, any performance gains you get from stopping event propagation will likely go unnoticed by your users. It’s a micro-optimization and certainly not your performance bottleneck.
I recommend not worrying about the fact that events propagate through the entire DOM. After all, it’s part of the specification, and browsers have gotten very good at doing it.
What To Do Instead
As a general rule, stopping event propagation should never be a solution to a problem. If you have a site with several event handlers that sometimes interfere with each other, and you discover that stopping propagation makes everything work, that’s a bad thing. It might fix your immediate problem, but it’s probably creating another one you’re not aware of.
Stopping propagation should be thought of like canceling an event, and it should only be used with that intent. Perhaps you want to prevent a form submission or disallow focus to an area of the page. In these cases you’re stopping propagation because you don’t want an event to happen, not because you have an unwanted event handler registered higher up in the DOM.
In the “How to detect a click outside of an element?” example above, the purpose of calling stopPropagation
isn’t to get rid of the click event altogether, it’s to avoid running some other code on the page.
In addition to this being a bad idea because it alters global behavior, it’s a bad idea because it puts the menu hiding logic in two different and unrelated places, making it far more fragile than necessary.
A much better solution is to have a single event handler whose logic is fully encapsulated and whose sole responsibility is to determine whether or not the menu should be hidden for the given event.
As it turns out, this better option also ends up requiring less code:
$(document).on('click', function(event) {
if (!$(event.target).closest('#menucontainer').length) {
// Hide the menus.
}
});
The above handler listens for clicks on the document and checks to see if the event target is #menucontainer
or has #menucontainer
as a parent. If it doesn’t, you know the click originated from outside of #menucontainer
, and thus you can hide the menus if they’re visible.
Default Prevented?
About a year ago I start writing an event handling library to help deal with this problem. Instead of stopping event propagation, you would simply mark an event as “handled”. This would allow event listeners registered farther up the DOM to inspect an event and, based on whether or not it had been “handled”, determine if any further action was needed. The idea was that you could “stop event propagation” without actually stopping it.
As it turned out, I never ended up needing this library. In 100% of the cases where I found myself wanting to check if an event hand been “handled”, I noticed that a previous listener had called preventDefault
. And the DOM API already provides a way to inspect this: the defaultPrevented
property.
To help clarify this, let me offer an example.
Imagine you’re adding an event listener to the document that will use Google Analytics to track when users click on links to external domains. It might look something like this:
$(document).on('click', 'a', function(event) {
if (this.hostname != 'css-tricks.com') {
ga('send', 'event', 'Outbound Link', this.href);
}
});
The problem with this code is that not all link clicks take you to other pages. Sometimes JavaScript will intercept the click, call preventDefault
and do something else. The data-remote
links described above are a prime example of this. Another example is a Twitter share button that opens a popup instead of going to twitter.com.
To avoid tracking these kinds of clicks, it might be tempting to stop event propagation, but inspecting the event for defaultPrevented
is a much better way.
$(document).on('click', 'a', function(event) {
// Ignore this event if preventDefault has been called.
if (event.defaultPrevented) return;
if (this.hostname != 'css-tricks.com') {
ga('send', 'event', 'Outbound Link', this.href);
}
});
Since calling preventDefault
in a click handler will always prevent the browser from navigating to a link’s address, you can be 100% confident that if defaultPrevented
is true, the user did not go anywhere. In other words, this technique is both more reliable than stopPropagation
, and it won’t have any side effects.
Conclusion
Hopefully this article has helped you think about DOM events in a new light. They’re not isolated pieces that can be modified without consequence. They’re global, interconnected objects that often affect far more code than you initially realize.
To avoid bugs, it’s almost always best to leave events alone and let them propagate as the browser intended.
If you’re ever unsure about what to do, just ask yourself the following question: is it possible that some other code, either now or in the future, might want to know that this event happened? The answer is usually yes. Whether it be for something as trivial as a Bootstrap modal or as critical as event tracking analytics, having access to event objects is important. When in doubt, don’t stop propagation.
The added benefit of the this solution is that the #menucontainer doesn’t have to be in the DOM at the time of the binding. As opposed to the suggested answer in Stackoverflow.
The money quote:
“When you alter the behavior that people expect and that other code depends on, you’re going to have bugs.”
Not “may”, not “might”, but “going to”. Undiscovered bugs are still bugs.
Philip, aren’t you forgetting to pass “event” to the last two jQuery function examples?
Yes, you’re correct. Thanks for catching the mistake.
Thank you Philip for updating mistake of “event”
“event” is a global var which is “undefined” unless inside a handler.
Again, prototype.js has all you need.
Your click event handler has to determine the element which received the click. Let’s assume it is stored in the variable ‘obj’. Then you just walk up:
Prototype.js has a complete solution for this: Event.findElement().
Of course, in a more general event handler you would probably use a virtual CSS class for all objects that need to be hidden when clicked outside, e.g. self-made dropdown boxes. This is fast enough even for mousemove events, and it’s compatible down to IE6 (if you really have to…). Prototype.js features a mouseleave polyfill, but the “up()” and “findElement()” approaches are more versatile.
This can only be sufficiently fast because most JS engines pre-compile the prototype extensions (the up() method, in this case). If you would have to parse the DOM “manually” each time an event occurs it would get much slower. And that’s one of the main reason why JS programmers should get acquainted to the prototype extension philosophy and abandon class or function based approaches. You don’t need Prototype.js for just this solution; The up() prototype extension takes just a few lines of code if you need to implement it on your own.
Ach, well, I’m sure there’s a similar solution using JQuery too…
This is my plain JavaScript equivalence for the jQuery code:
Is it good?
@Valtteri: No, you have it the wrong way around – the menu should get hidden, when the click happened outside of
#menucontainer
– withmenucontainer.contains(event.target)
however you are checking whether the target of the event is a descendant of#menucontainer
.I, too, have used something like the original Stackoverflow solution, which I found elegent until I ran into the same problem with third party code stopping the propagation and thus leaving dropdowns open.
However, if we can ignore IE8 and older, there is a workaround for third party code messing with events. We can use event capturing to handle the event on the document level before any other element receives it:
By passing in true as a third parameter, the event handler is registered to the capturing phase. Capturing means, that the event first makes it way from the document down to the event target, before the bubbling phase, which is the commonly known way from the target up to the document again. The order of capturing and bubbling is explained in the quirksmode article mentioned above.
There is a messy way even for IE8 and older: Listen to
mouseup
instead ofclick
, because that fires a tiny little earlier…The best solution, however, is still to do what the article says: do not stop propagation.
You can use transparent overlay to allow users to clicks outside the modal for closing without having to use event stop propagation:
Demo: http://codepen.io/anon/pen/Iicae
Here, the users thinks that they are clicking parts of the document outside of the modal, in fact they are clicking the
.overlay
element that covers the whole page.Hmph. The Markdown is broken, or it is because of
strip_tags()
in comments :(The empty selector above should be $(‘<div class=”overlay”></div>’)
Yes transparent overlays are good practice but they have a problem. Z-index. If you like to use local z-indices to prevent them from growing too big, it would hurt. Or you should make sure that none of the parent elements of the transparent overlays and popup menus are (esp. you’re not intended to) positioned and z-indexed locally.
I use jQuery focusout event to hide a form and it’s working just fine, or am I missing something?
I always use the following code to achieve this behavior:
@CBroe: Yeah, I mean this code:
(with the real ampersands)
I have always refused to use the events on the document to notify some other open element.
Somewhat like Kadhim I have always used a blur event on the menu. Then bubbling is not an issue.
I had been wondering about the distinction of
return false
amongst inline JS, jQuery, and traditional JS. Thank you and for the rest of the article.Very nice article! Because you start with detecting click outside an element, this is what I would recommend http://bassta.bg/2013/08/detect-click-event-outside-element/
var $box = $(“.box”);
Totally safe, and you don’t stop event propagation. When working with other people, you have to be sure, that you don’t stop any events from propagation/bubbling
Very Nice Article . Removed all instances of StopPropagation from my project. https://github.com/dekajp/google-closure-grid
While at its heart I agree with your premise that one shouldn’t prevent event bubbling haphazardly I must disagree with your suggestion that it’s better to create a rats’ nest of exception-handling code instead. Also, your question “is it possible that some other code, either now or in the future, might want to know that this event happened? The answer is usually yes.” is inaccurate as well. The answer isn’t “usually” yes, it “may be” yes.
I speak from experience as I just spent a day rewriting code AWAY from what you suggest. There were 4 different menus/popups that needed to close if the user clicked outside them (which by the way was the ONLY thing they needed to do and NO other code needed to know their state). In writing a global, always on listener I was forced to write literally dozens upon dozens of exceptions for all the other clicks that needed to do something else. I think it makes far more sense to listen for the “closing click” only if the menu/popup has been opened.
“Looks like you opened the menu – just let me know when you’re done with it” makes FAR more sense than “You clicked somewhere on the page! Did you click on this? No. Was it this? No. Was it this? No. Was it THIS? No. How about this? No. This? No. this? No. etc etc etc”.