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:
- The JavaScript engine starts executing scripts, placing synchronous operations on the call stack.
- 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. - 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.
- The event loop continuously monitors the call stack and executes items on the
call stack. If/when the call stack is empty:
- Microtask queue is processed. Microtasks include promise callbacks
(
then,catch,finally),MutationObservercallbacks, and calls toqueueMicrotask(). 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. - 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!- If the microtask queue is non-empty, process them as per the previous step.
- If the microtask queue is empty, the next macrotask queue callback is processed. This repeats until the macrotask queue is empty.
- Microtask queue is processed. Microtasks include promise callbacks
(
- 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.
- 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
// 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.
- 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.targetto 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: trueon thedocumentobject 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
- 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.
- 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.
- 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.targetorevent.currentTargetto determine which specific child element was interacted with. - 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.