Unfortunately, a search for "when to use stopPropagation()" and "when to call stopPropagation()" on Google turns up few answers except a number of very and semi-flawed articles related to the topic, but none of which answer the question of when it is okay to use stopPropagation(). stopPropagation() exists and therefore is meant to be used...but when?
It's time to remedy both the misinformation and provide the correct answer on when to call preventDefault() and stopPropagation() as well as setTimeout(). I promise setTimeout() is semi-related.
Event handling in web browsers is quite difficult for most people to grasp...even apparently for the experts! There are 85+ events to consider when writing custom Javascript bits. Fortunately, there are only a few in that list that are commonly used:
keydown, keyup, keypress
mouseenter, mousedown, mousemove, mouseup, mouseleave, wheel
touchstart, touchmove, touchend
click, input, change
scroll, focus, blur
load, submit, resize
I tried to group those into various categories and most should be pretty obvious as to what they do (e.g. 'click' means something was clicked, 'mousemove' means the mouse moved). But they are organized by: Keyboard, mouse, touchscreen, input elements, focus and scrolling, and miscellaneous events.
Digging into browser events
The web browser fires events in a specific order: Capturing then bubbling. What exactly does that mean? Let's use a picture of what happens:
The above diagram will be referenced as I go along. When I mention, "Step 5" or "Step 2" or some such, I am referring to this specific diagram.
If code like the following is written:
<style type="text/css">
.otherclass { width: 50px; height: 50px; background-color: #000000; }
</style>
<div class="theclass"><div class="otherclass"></div></div>
<script>
(function() {
var elem = document.getElementsByClassName('theclass')[0];
var MyEventHandler = function(e) {
console.log(e);
console.log(e.target);
console.trace();
};
elem.addEventListener('click', MyEventHandler);
window.addEventListener('click', MyEventHandler);
})();
</script>
That will set up two bubbling event handlers. In this case, a click handler is applied to the div with the class 'theclass' and the window. When a user clicks the div inside of it, the 'click' event arrives in MyEventHandler at step 7 and again in step 10 in the earlier graphic. The browser walks down the hierarchy toward the target in the capturing phase and then moves back up to the window in the bubbling phase, firing registered event listeners in that order and only stops if it reaches the end OR a function calls stopPropagation().
When an event arrives, the 'e.target' contains the element with the target node in the DOM that resulted in the event being created. The 'e.target' is the single most important piece of information as it contains the DOM node that triggered the event.
Useful tip: Instead of registering events on every single button, div, and doodad in the hierarchy, it can be far more efficient to register a single event on a parent element of a group of nodes that share similar characteristics. Using 'data-'/dataset attributes can then allow lookups to be performed in O(1) time even if there are 500+ children.
What can go wrong: An example
Before diving into preventDefault() and stopPropagation(), let's look at what happens if there's a lack of understanding of how events and event propagation work:
In the example above, Bootstrap is used to show a menu of options when the "Dropdown" button is clicked. The menu closes as expected when clicking the "Normal Button" but it does NOT close when clicking the "Remote Link" button. The "Remote Link" button is using another library to handle 'click' events, which calls stopPropagation() and there is a bubbling 'click' event handler somewhere on the document.
The author of The Dangers of Stopping Event Propagation blames the authors of 'jquery-ujs' for calling stopPropagation() but we'll see momentarily that there are actually TWO bugs - one in 'jquery-ujs' and the other in Twitter Bootstrap...both bugs happen because the authors of both libraries don't actually understand the browser event model and the two libraries therefore collide in spectacular fashion when presented with a common scenario. The author of the article also makes a recommendation toward the end of the article that leads to unfortunate situations. Mind you, that article is near the top of Google Search results!
Understanding preventDefault() and stopPropagation()
Let's look at preventDefault() as it causes some confusion as to what it is used for. preventDefault() prevents the default browser action. For example, pressing the 'Tab' key on the keyboard has a default action of moving to the next element in the DOM that has a 'tabIndex'. Calling preventDefault() in a 'keydown' event handler tells the browser you don't want the browser to do the default action. The browser is free to ignore that and do whatever it wants but it will usually take the hint.
When should you call preventDefault()? When you know that the browser will do something you don't want it to do if you do not call it. In other words, generally don't call it and see what happens. If the default browser behavior does something undesireable, then and only then figure out precisely when and where to call preventDefault(). Overriding the default behavior should always make sense to the end-user. For example, if preventDefault() is called in a 'keydown' handler and the user presses 'Tab', the handler should do something sensible to move the focus to the "next" element. If they press 'Shift + Tab', the handler should go to the "previous" element.
Now let's look at stopPropagation() as it causes even MORE confusion as to what it actually does. When 'e.stopPropagation()' is called, the browser finishes calling all the events at the current step of the process and then stops running event callbacks. There is one exception for the 'e.target' node, which processes both step 5 AND step 6 even if stopPropagation() is called in step 5. (These "steps" are referring to the diagram from earlier.)
The problem with calling stopPropagation() is it stops event handling dead in its tracks. This creates problems for listeners further along as events they are listening for aren't being delivered. For example, if 'mousedown' propagates to a parent that is listening for 'mousedown' in order to start doing something and then listens for a matching bubbling 'mouseup' event but something else calls stopPropagation() in its own 'mouseup' handler, then the 'mouseup' never arrives and the user interface breaks!
Some people have suggested to call preventDefault() and use 'e.defaultPrevented' to not handle an event instead of stopPropagation(). However, this idea is problematic because it also tells the browser to not perform its default action. That can introduce a lot of subtle bugs too when going to do more advanced stuff. For example, calling preventDefault() in a 'mousedown' handler on a node that has 'draggable' set to 'true' will cause a 'dragstart' to never being called leading to all kinds of frustration. It is also improper to simply look at 'e.defaultPrevented' and return to a caller without doing anything else.
Suffice it to say that using 'e.defaultPrevented' won't work either. So what works? The correct answer is to cautiously call preventDefault(), only occasionally look at 'e.defaultPrevented' in combination with looking at the DOM hierarchy (usually to break a loop), and extremely rarely, if ever call stopPropagation().
Answering the question
Now let's answer the original question, "When is it actually okay to use stopPropagation()?" The correct answer is to only call stopPropagation() in "modals." The modal in a web browser is a little bit more fluid of a definition than "a child window blocking access to a parent window until it is closed," but the concept is similar. In this case, it is something we want to trap in a sandbox where it makes no sense to allow events to continue to propagate down/up the DOM tree.
An example could be a dropdown menu that allows the user to navigate the menu with both the mouse and the keyboard. For the mouse, a 'mousedown' anywhere on the menu results in selecting an item while clicking off the menu elsewhere on the page closes the menu (cancels) and carries out a different action elsewhere. This is an example where calling stopPropagation() would be the wrong thing to do because doing so would block the mouse from acting normally, requiring extra clicks to do things.
For the keyboard though, it is a completely different story. The keyboard should have focus on the menu and the focus should remain trapped there in that sandbox until the user navigates away with the keyboard (or uses the mouse). This is expected behavior! Keyboard events (keydown/keyup/keypress) are involved with a totally different user experience than mouse events. Keyboard navigation always follows a sequential set of steps.
In the case of a dropdown menu, pressing 'Escape' or 'Tab' on the keyboard should exit the menu. However, if the event is allowed to propagate up the DOM tree, pressing the Escape key might also cancel a parent dialog (another modal!). stopPropagation() is the correct solution for keyboard events where the keyboard focus is in a modal. Mouse and touch events are almost never modal unless you are displaying a true modal on the screen. As such, the keyboard can wind up in modal-style situations much more frequently and therefore stopPropagation() is the correct solution.
Putting it all together
Okay, let's go back to the Bootstrap/jquery-ujs example from before and figure out how to solve the problem using our new understanding of the browser event model. We know that calling stopPropagation() in the "Remote Link" button handler was the wrong thing to do because it caused Bootstrap to not be able to close the popup. However, remember I said there were TWO bugs here? Bootstrap is incorrectly watching for a bubbling event to close the dropdown. If you look at both the earlier diagram and the list of events, can you figure out which event Bootstrap should be looking for and where in the steps it should be watching for that event?
.
.
.
.
.
.
.
.
.
.
.
.
.
If you guessed a capturing focus change event on the window (aka Step 1), then you would be correct! It would look something like:
window.addEventListener('focus', CloseDropdownHandler, true);
The handler would have to make sure that the target element for the focus change event was still within the dropdown's popup but that's a simple matter of walking up the 'parentNode' list looking for the wrapper element for the popup. If the popup is not in the hierarchy from 'e.target' to the window, then the user went elsewhere and it is time to cancel the popup. This also avoids the situation where another library might interfere by incorrectly calling stopPropagation() and the number of events that have to be registered in the browser to catch all possible situations is also reduced!
On setTimeout()
While we are on the topic of element focus, handling element focus is a huge source of preventDefault()/stopPropagation() headaches. This can lead to some really ugly hacks involving setTimeout() that don't need to exist such as:
var elem = origelem;
// But somelem or one of its children has the focus!
someelem.parentNode.removeChild(somelem);
// Doesn't appear to work...
elem.focus();
// But this does work.
setTimeout(function() {
elem.focus();
}, 0);
This happens when improper focus changes cause the 'document.body' element to be focused because the focused element was removed from the DOM too soon. Calling setTimeout() with 0 milliseconds in order to change focus after all of the events have settled is always a hack. setTimeout()/setInterval() only run after completing a UI update, which is why the second 'elem.focus()' inside the setTimeout() above "works." But for a brief moment, the focus is on the body element, which can wreak all kinds of havoc.
stopPropagation() is sometimes used in conjunction with this hack to prevent, say, CSS classes from being removed that affect visual appearance without those classes (e.g. resulting in visual flashing from the CSS class being removed and re-added a moment later). All of that results in a jarring mouse and keyboard user experience and lots of workarounds for workarounds. This hack can be resolved by first moving focus to another focusable element that won't be removed before removing the element from the DOM that currently has the focus:
var elem = origelem;
// Now elem has the focus.
elem.focus();
// somelem can be removed safely.
someelem.parentNode.removeChild(somelem);
// No hacky setTimeout()!
There are very few instances where calling setTimeout() is totally legit - maybe use it for just the occasional things that actually timeout? When setTimeout() is used for something other than a timeout, there is almost always something that has been overlooked and could be done differently that's better for everyone.
Conclusion
Hope you learned something here about capturing/bubbling events and how preventDefault() and stopPropagation() work in that context. The event model diagram from earlier is probably the cleanest, most accurate representation of the web browser capturing/bubbling event model I've ever seen. That diagram might even be printer-worthy! Maybe not "put it in a picture frame and hang it up on a wall"-worthy but possibly fine for a printed page.
This article was originally published to CubicSpot on Blogger
Top comments (5)
By the way, I'm really loving this community. Unlike many of the sites I've been to like StackOverflow, Reddit, HackerNews, etc., my experience so far is that everyone here on Dev.to seems delightful to interact with! It's always great to find a nice place with nice people.
Love the article, indeed it's one of my most common headaches although I "think" or "thought" I understood it lol.
I would ask this question about defaultPrevented though - frequently you are using an library and it's not bothering with that
defaultPrevented
stuff. So stopPropagation appears to be the only way to handle it.e.g. React Material UI expansion card header with a button on it. UX is right, user understands the button. If I don't call stopPropagation on the click event of the button then the expansion card will collapse or expand - preventDefault seems to be not enough. Indeed I want the whole above chain to be forgotten, which is why I'm doing it I guess... Do you have an insight I could apply in this case?
Could you post a Fiddle/CodePen example that demonstrates this? It sounds like a tricky problem. As a user of a third-party library, you have little control over the library itself, including its bugs, other than opening an issue on an issue tracker. You could also roll your own expansion card header widget that doesn't have the buggy behavior (e.g. maybe it doesn't collapse on click when the
e.target
is an 'a', 'input', or 'button' element or has an attribute likedata-collapse-on-click="false"
).preventDefault() should only be used to tell the browser to not do anything with the button. If the button is in a
form
element, then that might be enough to trigger the browser to send a form. You would call preventDefault() to stop the browser from sending the form when the button is clicked, not to prevent side-effects upstream in the DOM. As mentioned in the article, preventDefault() can also be used to stop some later events from firing without stopping the event from being sent along the chain so there might be something you could do here to prevent the 'click' event from firing. But as I said, preventDefault() is a suggestion to the browser - it can choose to ignore it and do whatever it wants (i.e. prevent means you want to prevent it but there's no guarantee). Since it is a mechanism for telling the browser to not do something, defaultPrevented shouldn't be the mechanism that widgets rely on.stopPropagation() completely halts processing of the event along the chain. If there's something later in the event chain, usually in the bubbling phase, listening for an event to react to it, it won't receive it. It's still a judgement call as to what is "modal" and for what input devices is it modal for (keyboard, mouse, touch). Is the button modal? Is the whole card modal (compared to the document/page)? Will anything upstream of the card in the DOM ever need to hear the 'click' event to react to it?
stopPropagation() is generally overkill. The ideal quick-n-dirty solution to handling problematic/buggy widgets without having to dig into their source code would be a way to tell the browser to skip passing an event to specific DOM elements as it passes through our event handlers. For example, a
dontPropagateTo(node, capture)
function that accepts one or more DOM nodes and capturing/bubbling phase to skip during the processing of an event. That would allow for developers to say to the browser, "I know these particular DOM elements may have previously registered event handlers for the click event but they don't need to know about this particular click event and there might be something important further along the event propagation chain that could be listening for click events that I don't know about." Such functionality would mostly solve your problem without potentially breaking event handling for other listeners. The downside is that all listeners on those specified DOM nodes would not receive the event - not just the buggy one. Still, such a function could be very useful.Rolling your own widget may actually be the "best" option. That way you have total control over the way events are handled. The downside is that it takes time to build a good, custom widget from the ground-up. However, you might be able to take the existing source code for the widget and slightly modify it to ignore processing certain event targets in its click handler. That way you don't have to call stopPropagation() in your click handler(s).
Very nice article!
Thanks!