javascriptnotesEvent handling

Event loop

The event loop is concept within the browser runtime environment regarding how asynchronous operations are executed within JavaScript engines. It works as such:

  1. The JavaScript engine starts executing scripts, placing synchronous operations on the call stack.
  2. When an asynchronous operation is encountered (e.g., setTimeout(), HTTP request), it is offloaded to the respective Web API or Node.js API to handle the operation in the background.
  3. Once the asynchronous operation completes, its callback function is placed in the respective queues – task queues (also known as macrotask queues / callback queues) or microtask queues. We will refer to “task queue” as “macrotask queue” from here on to better differentiate from the microtask queue.
  4. The event loop continuously monitors the call stack and executes items on the call stack. If/when the call stack is empty:
    1. Microtask queue is processed. Microtasks include promise callbacks (then, catch, finally), MutationObserver callbacks, and calls to queueMicrotask(). The event loop takes the first callback from the microtask queue and pushes it to the call stack for execution. This repeats until the microtask queue is empty.
    2. Macrotask queue is processed. Macrotasks include web APIs like setTimeout(), HTTP requests, user interface event handlers like clicks, scrolls, etc. The event loop dequeues the first callback from the macrotask queue and pushes it onto the call stack for execution. However, after a macrotask queue callback is processed, the event loop does not proceed with the next macrotask yet! The event loop first checks the microtask queue. Checking the microtask queue is necessary as microtasks have higher priority than macrotask queue callbacks. The macrotask queue callback that was just executed could have added more microtasks!
      1. If the microtask queue is non-empty, process them as per the previous step.
      2. If the microtask queue is empty, the next macrotask queue callback is processed. This repeats until the macrotask queue is empty.
  5. This process continues indefinitely, allowing the JavaScript engine to handle both synchronous and asynchronous operations efficiently without blocking the call stack.

Event bubbling

Event bubbling is a DOM event propagation mechanism where an event (e.g. a click), starts at the target element and bubbles up to the root of the document. This allows ancestor elements to also respond to the event.

Event bubbling is essential for event delegation, where a single event handler manages events for multiple child elements, enhancing performance and code simplicity. While convenient, failing to manage event propagation properly can lead to unintended behavior, such as multiple handlers firing for a single event.

Note: even before the event bubbling phase happens is the event capturing phase which is the opposite of bubbling where the event goes down from the document root to the target element.

  1. Bubbling phase

During the bubbling phase, the event starts at the target element and bubbles up through its ancestors in the DOM hierarchy. This means that the event handlers attached to the target element and its ancestors can all potentially receive and respond to the event.

Example
Here's an example using modern ES6 syntax to demonstrate event bubbling:
// HTML:
// <div id="parent">
//   <button id="child">Click me!</button>
// </div>
 
const parent = document.getElementById("parent");
const child = document.getElementById("child");
 
parent.addEventListener("click", () => {
  console.log("Parent element clicked");
});
 
child.addEventListener("click", () => {
  console.log("Child element clicked");
});

When you click the “Click me!” button, both the child and parent event handlers will be triggered due to the event bubbling.

  1. Stopping the bubbling

Event bubbling can be stopped during the bubbling phase using the stopPropagation() method. If an event handler calls stopPropagation(), it prevents the event from further bubbling up the DOM tree, ensuring that only the handlers of the elements up to that point in the hierarchy are executed.

Example
child.addEventListener("click", (event) => {
  console.log("Child element clicked");
  event.stopPropagation();
});

Benefits

  • Cleaner code: Reduced number of event listeners improves code readability and maintainability.
  • Efficient event handling: Minimizes performance overhead by attaching fewer listeners.
  • Flexibility: Allows handling events happening on child elements without directly attaching listeners to them.

Pitfalls

  • Accidental event handling: Be mindful that parent elements might unintentionally capture events meant for children. Use event.target to identify the specific element that triggered the event.
  • Event order: Events bubble up in a specific order. If multiple parents have event listeners, their order of execution depends on the DOM hierarchy.
  • Over-delegation: While delegating events to a common ancestor is efficient, attaching a listener too high in the DOM tree might capture unintended events.

Use cases

Reducing code with event delegation

Imagine you have a product list with numerous items, each with a “Buy Now” button. Traditionally, you might attach a separate click event listener to each button:

// HTML:
// <ul id="product-list">
//   <li><button id="item1-buy">Buy Now</button></li>
//   <li><button id="item2-buy">Buy Now</button></li>
// </ul>
 
const item1Buy = document.getElementById("item1-buy");
const item2Buy = document.getElementById("item2-buy");
 
item1Buy.addEventListener("click", handleBuyClick);
item2Buy.addEventListener("click", handleBuyClick);
 
// ... repeat for each item ...
 
function handleBuyClick(event) {
  console.log("Buy button clicked for item:", event.target.id);
}

This approach becomes cumbersome as the number of items grows. Here’s how event bubbling can simplify things:

// HTML:
// <ul id="product-list">
//   <li><button id="item1-buy">Buy Now</button></li>
//   <li><button id="item2-buy">Buy Now</button></li>
// </ul>
 
const productList = document.getElementById("product-list");
 
productList.addEventListener("click", handleBuyClick);
 
function handleBuyClick(event) {
  // Check if the clicked element is a button within the list
  if (event.target.tagName.toLowerCase() === "button") {
    console.log("Buy button clicked for item:", event.target.textContent);
  }
}

By attaching the listener to the parent (productList) and checking the clicked element (event.target) within the handler, you achieve the same functionality with less code. This approach scales well when the items are dynamic as no new event handlers have to be added or removed when the list of items change.

Dropdown menus

Consider a dropdown menu where clicking anywhere on the menu element (parent) should close it. With event bubbling, you can achieve this with a single listener:

// HTML:
// <div id="dropdown">
//   <button>Open Menu</button>
//   <ul>
//     <li>Item 1</li>
//     <li>Item 2</li>
//   </ul>
// </div>
 
const dropdown = document.getElementById("dropdown");
 
dropdown.addEventListener("click", handleDropdownClick);
 
function handleDropdownClick(event) {
  // Close the dropdown if clicked outside the button
  if (event.target !== dropdown.querySelector("button")) {
    console.log("Dropdown closed");
    // Your logic to hide the dropdown content
  }
}

Here, the click event bubbles up from the clicked element (button or list item) to the dropdown element. The handler checks if the clicked element is not the <button> and closes the menu accordingly.

Accordion menus

Imagine an accordion menu where clicking a section header (parent) expands or collapses the content section (child) below it. Event bubbling makes this straightforward:

// HTML:
// <div class="accordion">
//   <div class="header">Section 1</div>
//   <div class="content">Content for Section 1</div>
//   <div class="header">Section 2</div>
//   <div class="content">Content for Section 2</div>
// </div>
 
const accordion = document.querySelector(".accordion");
 
accordion.addEventListener("click", handleAccordionClick);
 
function handleAccordionClick(event) {
  // Check if clicked element is a header
  if (event.target.classList.contains("header")) {
    const content = event.target.nextElementSibling;
    content.classList.toggle("active"); // Toggle display of content
  }
}

By attaching the listener to the accordion element, clicking on any header triggers the event. The handler checks if the clicked element is a header and toggles the visibility of the corresponding content section.

Event capturing

Event capturing is a lesser-used counterpart to event bubbling in the DOM event propagation mechanism. It follows the opposite order, where an event triggers first on the ancestor element and then travels down to the target element.

Event capturing is rarely used as compared to event bubbling, but it can be used in specific scenarios where you need to intercept events at a higher level before they reach the target element. It is disabled by default but can be enabled through an option on addEventListener().

Capturing has a higher priority than bubbling, meaning that capturing event handlers are executed before bubbling event handlers, as shown by the phases of event propagation:

  • Capturing phase: The event moves down towards the target element
  • Target phase: The event reaches the target element
  • Bubbling phase: The event bubbles up from the target element

Capturing phase

During the capturing phase, the event starts at the document root and propagates down to the target element. Any event listeners on ancestor elements in this path will be triggered before the target element’s handler. But note that event capturing can’t happen until the third argument of addEventListener() is set to true as shown below (default value is false).

Example
// HTML:
// <div id="parent">
//   <button id="child">Click me!</button>
// </div>
 
const parent = document.getElementById("parent");
const child = document.getElementById("child");
 
parent.addEventListener(
  "click",
  () => {
    console.log("Parent element clicked (capturing)");
  },
  true // Set third argument to true for capturing
);
 
child.addEventListener("click", () => {
  console.log("Child element clicked");
});

When you click the “Click me!” button, it will trigger the parent element’s capturing handler first, followed by the child element’s handler.

Stopping propagation

Event propagation can be stopped during the capturing phase using the stopPropagation() method. This prevents the event from traveling further down the DOM tree.

Example
// HTML:
// <div id="parent">
//   <button id="child">Click me!</button>
// </div>
 
const parent = document.getElementById("parent");
const child = document.getElementById("child");
 
parent.addEventListener(
  "click",
  (event) => {
    console.log("Parent element clicked (capturing)");
    event.stopPropagation(); // Stop event propagation
  },
  true
);
 
child.addEventListener("click", () => {
  console.log("Child element clicked");
});

As a result of stopping event propagation, just the parent event listener will now be called when you click the “Click Me!” button, and the child event listener will never be called because the event propagation has stopped at the parent element.

Uses of event capturing

Event capturing is rarely used as compared to event bubbling, but it can be used in specific scenarios where you need to intercept events at a higher level before they reach the target element.

  • Stopping event bubbling: Imagine you have a nested element (like a button) inside a container element. Clicking the button might also trigger a click event on the container. By using enabling event capturing on the container’s event listener, you can capture the click event there and prevent it from traveling down to the button, potentially causing unintended behavior.
  • Custom dropdown menus:: When building custom dropdown menus, you might want to capture clicks outside the menu element to close the menu. Using capture: true on the document object allows you to listen for clicks anywhere on the page and close the menu if the click happens outside its boundaries.
  • Efficiency in certain scenarios:: In some situations, event capturing can be slightly more efficient than relying on bubbling. This is because the event doesn’t need to propagate through all child elements before reaching the handler. However, the performance difference is usually negligible for most web applications.

Event delegation

Event delegation is a design pattern in JavaScript used to efficiently manage and handle events on multiple child elements by attaching a single event listener to a common ancestor element. This pattern is particularly valuable in scenarios where you have a large number of similar elements, such as list items, and want to optimize event handling.

Example
// HTML:
// <ul id="item-list">
//   <li>Item 1</li>
//   <li>Item 2</li>
//   <li>Item 3</li>
// </ul>
 
const itemList = document.getElementById("item-list");
 
itemList.addEventListener("click", (event) => {
  if (event.target.tagName === "LI") {
    console.log(`Clicked on ${event.target.textContent}`);
  }
});

In this example, a single click event listener is attached to the <ul> element. When a click event occurs on an <li> element, the event bubbles up to the <ul> element, where the event listener checks the target’s tag name to identify whether a list item was clicked. It’s crucial to check the identity of the event.target as there can be other kinds of elements in the DOM tree.

How event delegation works

  1. Attach a listener to a common ancestor: Instead of attaching individual event listeners to each child element, you attach a single event listener to a common ancestor element higher in the DOM hierarchy.
  2. Event bubbling: When an event occurs on a child element, it bubbles up through the DOM tree to the common ancestor element. During this propagation, the event listener on the common ancestor can intercept and handle the event.
  3. Determine the target: Within the event listener, you can inspect the event object to identify the actual target of the event (the child element that triggered the event). You can use properties like event.target or event.currentTarget to determine which specific child element was interacted with.
  4. Perform action based on target: Based on the target element, you can perform the desired action or execute code specific to that element. This allows you to handle events for multiple child elements with a single event listener.

Benefits of event delegation

  • Improved performance: Attaching a single event listener is more efficient than attaching multiple event listeners to individual elements, especially for large or dynamic lists. This reduces memory usage and improves overall performance.
  • Simplified event handling: With event delegation, you only need to write the event handling logic once in the parent element’s event listener. This makes the code more maintainable and easier to update.
  • Dynamic element support: Event delegation automatically handles events for dynamically added or removed elements within the parent element. There’s no need to manually attach or remove event listeners when the DOM structure changes

Use cases

Handling dynamic content in single-page applications
// HTML:
// <div id="button-container">
//   <button>Button 1</button>
//   <button>Button 2</button>
// </div>
// <button id="add-button">Add Button</button>
 
const buttonContainer = document.getElementById("button-container");
const addButton = document.getElementById("add-button");
 
buttonContainer.addEventListener("click", (event) => {
  if (event.target.tagName === "BUTTON") {
    console.log(`Clicked on ${event.target.textContent}`);
  }
});
 
addButton.addEventListener("click", () => {
  const newButton = document.createElement("button");
  newButton.textContent = `Button ${buttonContainer.children.length + 1}`;
  buttonContainer.appendChild(newButton);
});

In this example, a click event listener is attached to the <div> container. When a new button is added dynamically and clicked, the event listener on the container handles the click event.

Simplifying code by avoiding the need to attach and remove event listeners for elements that change
// HTML:
// <form id="user-form">
//   <input type="text" name="username" placeholder="Username">
//   <input type="email" name="email" placeholder="Email">
//   <input type="password" name="password" placeholder="Password">
// </form>
 
const userForm = document.getElementById("user-form");
 
userForm.addEventListener("input", (event) => {
  const { name, value } = event.target;
  console.log(`Changed ${name}: ${value}`);
});

In this example, a single input event listener is attached to the form element. It can respond to input changes for all child input elements, simplifying the code by an event listeners per <input> element.

Pitfalls

Do note that event delegation come with certain pitfalls:

  • Incorrect target handling: Ensure correct identification of the event target to avoid unintended actions.
  • Not all events can be delegated/bubbled: Not all events can be delegated because they are not bubbled. Non-bubbling events include: focus, blur, scroll, mouseenter, mouseleave, resize, etc.
  • Event overhead: While event delegation is generally more efficient, there needs to be complex logic written within the root event listener to identify the triggering element and respond appropriately. This can introduce overhead and can be potentially more complex if not managed properly.

Event delegation in JavaScript frameworks

In React, event handlers are attached to the React root’s DOM container into which the React tree is rendered. Even though onClick is added to child elements, the actual event listeners are attached to the root DOM node, leveraging event delegation to optimize event handling and improve performance.

When an event occurs, React’s event listener captures it and determines which React component rendered the target element based on its internal bookkeeping. React then dispatches the event to the appropriate component’s event handler by calling the handler function with a synthetic event object. This synthetic event object wraps the native browser event, providing a consistent interface across different browsers and capturing information about the event.

By using event delegation, React avoids attaching individual event handlers to each component instance, which would create significant overhead, especially for large component trees. Instead, React leverages the browser’s native event bubbling mechanism to capture events at the root and distribute them to the appropriate components.

Event-driven programming

Event-driven programming is a paradigm that involves building applications that send and receive events. When the program emits events, the program responds by running any callback functions that are registered to that event and context, passing in associated data to the function. With this pattern, events can be emitted into the wild without throwing errors even if no functions are subscribed to it.

A common example of this is the pattern of elements listening to DOM events such as click and mouseenter, where a callback function is run when the event occurs.

document.addEventListener("click", function (event) {
  // This callback function is run when the user
  // clicks on the document.
});

Without the context of the DOM, the pattern may look like this:

const hub = createEventHub();
hub.on("message", function (data) {
  console.log(`${data.username} said ${data.text}`);
});
hub.emit("message", {
  username: "John",
  text: "Hello?",
});

With this implementation, on is the way to subscribe to an event, while emit is the way to publish the event.

  • Follows a publish-subscribe pattern.
  • Responds to events that occur by running any callback functions subscribed to the event.
  • Show how to create a simple pub-sub implementation with JavaScript.