A journey of events

Hi, recently I had a task at work related to the behavior when we use Escape to close an opening file preview. We have a drawer (from a UI library), and from within the drawer we can open a custom file preview. When we press Escape, we expect to close the file preview, but instead the drawer closes while the file preview remains on the screen. You can visualize it as:

---- document
    |
    |---- drawer
        |
        |---- file preview

Interesting challenge!

Our Hotfix

In the implemented file preview, I added an event listener to the file preview component:

filePreview.tsx
1document.addEventListener("keydown", function (e) {
2 if (e.key === "Escape") {
3 console.log(">>> event"); // for debugging
4 setOpenPreview(false);
5 }
6});

After checking the implementation, I found that it never reached the handler of the file preview—the console showed nothing. My senior suggested trying to add true at the end of the addEventListener:

filePreview.tsx
1document.addEventListener(
2 "keydown",
3 function (e) {
4 if (e.key === "Escape") {
5 console.log(">>> event");
6 setOpenPreview(false);
7 }
8 },
9 true // adding this new line
10);

Now it works! Let's explore why ...

The Event Journey

As we can understand, the Drawer has its own escape event handler, so this is about handler priority. When there are 2 event handlers for the same event, which one will be handled first? This comes down to the concepts of event capturing and event bubbling.

Let's consider this case: we have an outer component with its handler for a click event, and an inner component with another handler for a click event:

---- outer component
    |
    |---- inner component

When we click the inner component, what will be the sequence, or the event order?

  • If the triggers the outer component first, then the inner, we have event capturing
  • If the triggers the inner component first, then the outer, we have event bubbling

In the past, different browsers supported different concepts, but nowadays, they support both.

Event Capturing

With event capturing, the event triggers from the outside to the target of the action. In our case: from the outermost component—if any (e.g., document/window)—then from outer to inner

                            EVENT CAPTURING

                                  ||
---- document                     ||
    |                             ||
    |---- outer component         ||
        |                         ||
        |---- inner component     \/

Event Bubbling

With event bubbling, the event triggers from the target of the action to the outside. In our case: from inner to outer, then to the outermost component—if any (e.g., document/window)

---- document                     /\
    |                             ||
    |---- outer component         ||
        |                         ||
        |---- inner component     ||
                                  ||

                            EVENT BUBBLING

Hybrid Model

As I mentioned, nowadays browsers support both mechanisms. First it captures down to the target, then once it reaches the target, it bubbles up again.

                                HYBRID MODEL

                                  ||  /\
---- document                     ||  ||
    |                             ||  ||
    |---- outer component         ||  ||
        |                         ||  ||
        |---- inner component     \/  ||

How can we control which mechanism we're using? The browser provides this control via the third parameter as a boolean inside addEventListener. According to the documentation:

A boolean value indicating whether events of this type will be dispatched to the registered listener before being dispatched to any EventTarget beneath it in the DOM tree. Events that are bubbling upward through the tree will not trigger a listener designated to use capture.

Tldr, this parameter determines whether you're using capturing or not:

  • addEventListener(type, listener, true): this applies event capturing
  • addEventListener(type, listener): this applies event bubbling (false is the default)
journey

With My Case:

Cool, enough theory, let's see how it works in practice. At the beginning, I applied:

filePreview.tsx
1document.addEventListener("keydown", function (e) {
2 if (e.key === "Escape") {
3 console.log(">>> event");
4 setOpenPreview(false);
5 }
6});

There are 2 key things to focus on here:

  • I added the event handler to document, the outermost component
  • I passed nothing to the third argument, which defaults to false, thus applying event bubbling

Let's visualize this with a diagram:

---- document       <-- my preview handler is here              /\
    |                                                           ||
    |---- drawer    <-- triggered here and stop, no bubble up   ||
        |                                                       ||
        |---- file preview                                      ||
                                                                ||

                                                          EVENT BUBBLING

At the drawer level, they stop the event from bubbling up by using event.stopPropagation();. This stops the event from propagating to the next step, whether in the capturing or bubbling phase. So in my case, if I want to close only the file preview by using Escape while keeping the drawer open, this is the solution:

filePreview.tsx
1document.addEventListener(
2 "keydown",
3 function (e) {
4 if (e.key === "Escape") {
5 event.stopPropagation(); // stop the event from bubbling up
6 setOpenPreview(false);
7 }
8 },
9 true // apply event capturing
10);

Let's see how this works:

                                                          EVENT CAPTURING

                                                                ||
---- document       <-- my preview handler is here (1)          ||
    |                                                           ||
    |---- drawer    <-- can not be triggered       (2)          ||
        |                                                       ||
        |---- file preview     <-- will be closed  (3)          \/

Step by step:

(1): We attached the event handler for closing the file preview here, and we're using capture mode, so this will be triggered first. We then stop the event from propagating further down by using stopPropagation

(2): This will not be triggered, since we stopped the event propagation at step (1)

(3): The file preview is the innermost component, but since we attached the handler at the document level, only the file preview will be closed.

Nice!

Conclusion

This was an interesting investigation to follow through. To be honest, I should have learned about this sooner, but better late than never. If you're having the same issue, I understand your frustration, and I hope this explanation helps you. Happy coding!