Skip to main content

When to actually use preventDefault(), stopPropagation(), and setTimeout() in Javascript event listeners

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, 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 this 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:

See the Pen Stop Propagation Demo

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.

Comments