The basic arsenal of the Frontend developer
February 13

Events in HTML explained

In this article, we will thoroughly explore working with events, including their dispatching, capturing, bubbling and termination.

Especially for this article, I have created a small interactive demo script that vividly illustrates all the phases of an event. The script will be published at the end of the article.

What are events?

The answer to this question may seem obvious, but before delving further, let's delve into some details first.

So, an event is a named object that can be dispatched in a specific way, resulting in the asynchronous call of one or more functions that listen for this event.

Despite the fact that working with events is tightly connected with JavaScript (event listeners are commonly JavaScript functions), there is no direct mention of it in the ECMA-262 specification; instead, the specification leaves this mechanism entirely to the responsibility of the HOST executor.

Most often, we deal with events in environments such as Web and Node.js. The first one is regulated by the HTML standard. In particular, working with events is described in section 8.1.8 Events. To be precise, this section mainly describes Event handlers. The event mechanism itself is the subject of a separate DOM standard, which also directly describes the DOM tree, its nodes, selectors, and so on.

Node.js, on the other hand, does not have its own standard or specification, but it has its internal documentation. Events here are represented by the node:events package and, in general, are very similar to the events of the DOM standard.

As I mentioned earlier, formally, the implementation of the event mechanism is entirely the responsibility of the HOST executor, which means there can be multiple implementations. In order to have a basis for further reasoning, since the DOM standard is the only official source describing events, we will continue to consider events in its context going forward.

Event Creation

Earlier, we talked about an event being an object. And this object has a specific interface described in the standard 2.2 Interface Event. To create a custom event, it is enough to use the new Event() constructor.

const event = new Event('myEvent');

Alternatively, you can use the initEvent() method.

const event = document.createEvent("Event");
event.initEvent("click");

However, this method is considered deprecated and is only left in the standard for backward compatibility with old code. In addition, this method does not support setting the composed flag.

Sometimes, along with the event, it is necessary to pass some additional information. In this case, a simple event may not be enough. Specifically for this case, the standard provides a separate CustomEvent interface, which differs from the usual one only by the presence of a readonly detail attribute.

const event = new CustomEvent('pet', {
  detail: {
    type: "dog",
    color: "black",
  },
});

const event = new CustomEvent('pet', {
  detail: {
    type: "cat",
    color: "white",
  },
});

For deeper customization, you can also use classes or prototype inheritance.

class MyEvent extends Event {
  constructor(eventInitDict) {
    super("myEvent", eventInitDict);
  }
  
  myProperty: "";
  
  myMethod: () => {};
}

const event = new MyEvent();

Event Dispatching

Simply creating an event is not enough. To make the created event practically useful, it needs to be dispatched on some object. Events can be either native (created by the browser itself) or synthetic (created by the developer through the Web API). Native events are dispatched by the browser itself at the certain moment and on the certain objects. Synthetic events, on the other hand, need to be manually dispatched using the dispatchEvent() method, available on all Web elements. In other words, event invocation always occurs with a specific attachment to a particular object (Web element).

const event = new Event("myEvent");

document.getElementById("my-element").dispatchEvent(event);

If we need to dispatch an event globally without binding it to a specific element, this can be done by triggering the event on the global Window object.

window.dispatchEvent(event);

Event Listening

Continuing the subject of practicality, when dispatching an event on an object, it is assumed that someone will be expecting this event. In order to wait for and react to a specific event, it is required to create a listener function on this object using the addEventListener method.

window.addEventListener("myEvent", (event) => {
  // The function will be called if the event "myEvent" is dispatched
  // on the global Window object.
});

If the listener is no longer needed, it can be removed using the removeEventListener method.

// It is important that the reference to the function itself is the same
// in both methods. Otherwise, the listener will not be removed because
// the engine won't be able to find it in the callback stack for this
// object.
const callback = (event) => {/* ... */};

window.addEventListener("myEvent", callback);
window.removeEventListener("myEvent", callback);

Event Phases

We've covered the basic mechanics, now let's move on to the details. The HTML standard defines several phases for an event:

NONE (numeric value 0) - The event hasn't been dispatched yet. At this phase, it is right after creation.

CAPTURING_PHASE (numeric value 1) - The capturing phase. During this phase, the event occurs after being dispatched and before it reaches the target object.

AT_TARGET (numeric value 2) - During this phase, the event occurs right at the moment it reaches the target object.

BUBBLING_PHASE (numeric value 3) - The bubbling phase. During this phase, the event occurs after being dispatched and after it has reached the target object.

While NONE and AT_TARGET are quite obvious, the explanations for the other two phases are required.

Event Capturing

Let's look at the following DOM tree structure.

<div id="block-1">
    <div id="block-2">
        <div id="block-3">
            <div id="block-4">
                <div id="block-5">
                    <div id="block-6">
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Let's imagine that we've attached event listeners to all objects and dispatched an event on the #block-5 element.

for (let i = 1; i <= 6; i++) {
  document.getElementById(`block-${i}`).addEventListener(
    "myEvent",
    () => {}
  );
}

const event = new Event("myEvent");

document.getElementById("block-5").dispatchEvent(event);

In this scenario, the listener will only be activated on the 5th block (AT_TARGET phase). However, the Web API allows parent containers to "capture" events from their children. To achieve this, the capture flag needs to be set on the event listener.

for (let i = 1; i <= 6; i++) {
  document.getElementById(`block-${i}`).addEventListener(
    "myEvent",
    () => {},
    {
      capture: true,
    }
  );
}

Now, by dispatching the event on the fifth block, the event will pass through all the parent elements in sequence before reaching its target - block 5. While the event is traversing from the first block to the fourth block, it will be in the CAPTURING_PHASE.

Event Bubbling

Let's take the same example. This time, we won't set the capture flag on the listeners. Instead, we'll set the bubbles flag during the event's invocation.

document.getElementById("block-5").dispatchEvent(event, {
  bubbles: true,
});

Now, having reached the target object, the event will go 'backwards', from the fourth block to the first, as if 'bubbling' up. At this point, the event will be in the BUBBLING_PHASE.

Phases Combination

As we can see from the examples above, event capturing and bubbling are controlled by settings in different places. Event capturing is activated on the listener itself, while bubbling is enabled at the time of the event invocation. This allows combining the phases in various ways. For example, one can enable capturing only on blocks 1 and 3, and then the rest can work in the bubbling phase. It is important to understand that one listener cannot work in multiple phases simultaneously. The phases occur in a strict order: CAPTURING_PHASE -> AT_TARGET -> BUBBLING_PHASE. Once a listener is triggered in a certain phase, it will not be invoked in the next phase.

If we look into the source code of the Chromium engine (at the time of writing this article, engine version 123.0.6292.1), it looks literally as follows.

dispatchEvent: function(
    eventType, eventFrom, eventFromAction, mouseX, mouseY, intents) {
  const path = [];
  let parent = this.parent;
  while (parent) {
    Array.prototype.push.call(path, parent);
    parent = parent.parent;
  }
  
  const event = new AutomationEvent(
      eventType, this.wrapper, eventFrom, eventFromAction, mouseX, mouseY,
      intents);
      
  // Dispatch the event through the propagation path in three phases:
  // - capturing: starting from the root and going down to the target's parent
  // - targeting: dispatching the event on the target itself
  // - bubbling: starting from the target's parent, going back up to the root.
  // At any stage, a listener may call stopPropagation() on the event, which
  // will immediately stop event propagation through this path.
  if (this.dispatchEventAtCapturing_(event, path)) {
    if (this.dispatchEventAtTargeting_(event, path)) {
      this.dispatchEventAtBubbling_(event, path);
    }
  }
},

However, there is nothing preventing us from attaching multiple listeners to a single object, for example:

document.getElementById("block-2").addEventListener("myEvent", callback, {
  capture: true,
});

document.getElementById("block-2").addEventListener("myEvent", callback, {
  capture: false,
});

Then it is possible to achieve the effect of a listener being triggered on the same object in both the CAPTURING_PHASE and the BUBBLING_PHASE.

Event Stopping

In addition to listening in one or another phase, the Web API also allows stopping the further propagation of this event. There are several ways to stop the propagation of an event or to cancel a specific event listener.

stopPropagation

In order to prevent the event from descending further down the tree in the CAPTURING_PHASE phase, or from bubbling up the tree in the BUBBLING_PHASE phase, at any stage, the listener can attempt to stop it by calling stopPropagation().

document.getElementById("block-3").addEventListener("myEvent",
  (event) => {
    event.stopPropagation();
    // событие небудет распространяться дальше и не достигнет блоков
    // #block-4 и #block-5
  }, {
    capture: true,
  }
);

document.getElementById("block-3").addEventListener("myEvent",
  (event) => {
    event.stopPropagation();
    // аналогично, в фазе BUBBLING_PHASE, событие будет остановлено и
    // не достигнет блоков #block-2 и #block-1
  }, {
    capture: false,
  }
);

document.getElementById("block-5").dispatchEvent(event, {
  bubbles: true,
});

stopImmediatePropagation

The examples above allow stopping the "vertical" propagation of an event through the DOM tree. However, there may be several listeners on a single element at once. The stopPropagation() method will not prevent the triggering of other event listener functions on the same object. Specifically for this case, another method stopImmediatePropagation() is provided in the HTML standard, which, in addition to vertical propagation, also stops horizontal propagation.

document.getElementById("block-3").addEventListener("myEvent", (event) => {
  event.stopImmediatePropagation();
});

document.getElementById("block-3").addEventListener("myEvent", (event) => {
  // этот прослушиватель вызван не будет
});

One-time listener

Sometimes it happens that we do not want to stop the propagation of the entire event, but only exclude a specific listener after it has already worked. To address this, the HTML standard provides the once flag for the listener.

document.getElementById("block-5").addEventListener("myEvent",
  () => {},
  {
    once: true,
  }
);

document.getElementById("block-5").dispatchEvent(event);

// during this call, the listener will already be cancelled
// and will not work
document.getElementById("block-5").dispatchEvent(event); 

AbortSignal

Another alternative way to stop events is the AbortSignal mechanism. Formally, the mechanism is considered experimental, however, it is currently supported by all popular browsers. The essence of the mechanism is that a reference to an AbortSignal object, which is managed by the AbortController, can be passed as a parameter to the listener. At some point, when the signal with the aborted flag is received, the event being listened to will be interrupted, and an exception will be thrown in the listener itself.

const controller = new AbortController();

document.getElementById("block-5").addEventListener("myEvent",
  () => {},
  {
    signal: controller.signal,
  }
);

controller.abort("Manual stop");
// or
controller.abort(); // a common AbortError will be generated automatically

This method is convenient because event interruption can be dynamically controlled "from the outside." Typically, this technique is used to stop fetch requests when they are still in progress but are no longer relevant. As an example, consider the situation where we are waiting for a response from a fetch request for some data from the server, but the parameters of the request have changed (the user has changed the filter or, for example, switched the page). In this case, there is no point in waiting for the response of the old, irrelevant request. It can be cancelled and a new one can be sent.

Conclusion

In this article, we have examined all the main aspects of working with events: their creation, dispatching, and handling. We have learned to intercept and stop events in various ways.

As promised, I am publishing an interactive script that demonstrates event handling in all phases.


My telegram channels:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

Русская версия: https://blog.frontend-almanac.ru/events