All About Events - Event Bubbling, Propagation and Delegation

All About Events - Event Bubbling, Propagation and Delegation
Photo by Alex Alvarez / Unsplash

In the world of programming, events serve as catalysts for action.

Like many languages, JavaScript leverages this concept to execute code in response to specific triggers. Whether a user interaction or a system occurrence, events provide a mechanism to initiate predetermined processes.

While the implementation may differ between client-side and server-side JavaScript, the fundamental principle remains consistent.

When an event is executed, it generates an object. This object, rich with relevant information about the event, is then passed to a designated event handler function.

Here is a simple on click event example in React:

import React from 'react';

function EventExample() {
  const handleClick = (event) => {
    console.log('Button clicked!');
    // This will log: Button clicked!

    console.log('Event details:', event);
    // This will log something like:
    // Event details: SyntheticBaseEvent {
    //   nativeEvent: PointerEvent,
    //   type: "click",
    //   target: button,
    //   currentTarget: button,
    //   eventPhase: 3,
    //   bubbles: true,
    //   cancelable: true,
    //   timeStamp: 1234567890,
    //   defaultPrevented: false,
    //   isTrusted: true,
    //   ...
    // }
  };

  return (
    <button onClick={handleClick}>
      Click me to log event
    </button>
  );
}

export default EventExample;

When a user interacts with an element, such as clicking a button, React executed a flow of information. The designated event handler function jumps into action, receiving an event object.

Within this event object lies a wealth of event-specific information. For instance, a click event might reveal the target element (event.target) and the type of interaction (event.type).

This mechanism empowers software engineers to create highly responsive and context-aware applications, where each interaction can be interpreted and acted upon with surgical precision.

Event handlers

You can imagine web applications as a sophisticated control center, similar to a universal remote for a smart home.

Within this digital system, HTML elements serve as tactile interfaces - buttons waiting to be pressed.

In this analogy, JavaScript is the mastermind, the programming wizard that breathes life into these interfaces.

Event handling in JavaScript is the art of orchestrating responses to user interactions. It's the invisible conductor, ensuring that each "button press" in your application triggers a precise sequence of actions. This mechanism transforms static web pages into dynamic, responsive environments.

Each user interaction - click, swipe, or keypress - invokes specific functions that alter the application's state, update the UI, or communicate with external services.

This event-driven paradigm is the heartbeat of modern web applications, enabling them to respond with agility and precision to users actions.

In JavaScript, an event handler is a specialized function that acts as a digital responder. It stands ready to execute precise code when a specific event unfolds in the application's lifecycle. This function serves as both a listener and an actor, forming the basis of interactive web experiences.

In the above example, function handleClick is the event handler.

Event flow

In the DOM's event propagation model, interactions traverse a 3 steps journey:

  1. Capturing,
  2. Targeting,
  3. Bubbling

The capturing phase initiates this journey. When a nested element receives a click, the event doesn't immediately reach its destination. Instead, it travels through the DOM hierarchy, touching each ancestor element in route. This top-down journey resembles a waterfall, with the event trickling from the loftiest DOM branches towards its intended target, alerting each parent along the way.

The target phase marks the main point in the event's journey because it's between capturing and bubbling. At this point, the event reaches its intended destination—the element that initially started the interaction.

This phase spotlights the event's epicenter. For instance, in a form submission, the form itself takes center stage. Event handlers interactions with this element come into play during this phase, responding directly to the event at its source. It's the moment of truth where the event meets its intended recipient, setting the stage for any immediate, element-specific reactions before the event begins its ascent through the DOM.

The bubbling phase, the final act in the event propagation trilogy, reverses the initial descent. Here, the event ascends through the DOM hierarchy, echoing from its origin upwards. It goes from the target element, through each parental layer, ultimately reaching "the sky" at the window object.

This upward journey is the default behavior for events attached through event handlers. Like bubbles rising in coca cola, the event goes through each ancestral element, offering a chance for higher-level handlers to respond to the interaction that occurred in their descendants.

Here is a visual of all 3 actions:

Now I will explain each of these actions with code examples.

Event capturing

Event capturing orchestrates a top-down journey through the DOM hierarchy when an interaction occurs.

When a nested element becomes the focus of a user's action, the event first manifests at the summit of the DOM tree. It then cascades through the ancestral elements, each parent acknowledging the event's presence before it reaches its final destination.

This process resembles a waterfall, with the event flowing from the highest levels of abstraction down to the specific target, allowing each interface layer to prepare for the impending interaction.

Let's see this example:

import React from 'react';

const EventBubblingDemo = () => {
  const logEvent = (e) => {
    console.log(`Event on ${e.currentTarget.tagName (${e.currentTarget.id})`);
  };

  return (
    <main id="main" onClickCapture={logEvent}>
      <section id="section" onClickCapture={logEvent}>
        <div id="div" onClickCapture={logEvent}>
          <button id="button" onClickCapture={logEvent}>
            Click me!
          </button>
        </div>
      </section>
    </main>
  );
};

export default EventBubblingDemo;

Here is what it looks like:

First, instead of using usual onClick, we use onClickCapture. This tells React to attach the event listener to the capturing phase instead of the bubbling phase.

When you click the button, here's what happens:

  • The event is first captured by the outermost element, the main tag. The console logs: Capture on MAIN (main)
  • The event then propagates down to the section element: Capture on SECTION (section)
  • It continues to the div: Capture on DIV (div)
  • Finally, it reaches the target, the button: Capture on BUTTON (button)

The event handlers are executed from the outermost element inward, opposite to bubbling. Each element along the path gets a chance to react to the event before it reaches the target.

Event capturing is less commonly used than bubbling but it can be powerful in certain scenarios. In most cases, you'll work with the bubbling phase (onClick), but understanding both phases gives you more control over event handling in your applications.

Event bubbling

Event bubbling epitomizes the upward propagation of interactions through the DOM's hierarchical structure. In this phase, the event's influence ripples outward from its point of origin, ascending through the layers of the document.

This phenomenon mirrors the journey of a bubble in water, rising from its source to the surface. The event, born at a specific element, doesn't remain isolated. Instead, it ascends through the DOM tree, touching each ancestral node in succession. This upward cascade continues until it reaches "the sky" of the document structure - the window object.

This elegant propagation mechanism allows for a nuanced interplay between specific and general event handlers.

Let's see this code example:

import React from 'react';

const EventBubblingDemo = () => {
  const logEvent = (e) => {
    console.log(`Event on ${e.currentTarget.tagName} (${e.currentTarget.id})`);
  };

  return (
    <main id="main" onClick={logEvent}>
      <section id="section" onClick={logEvent}>
        <div id="div" onClick={logEvent}>
          <button id="button" onClick={logEvent}>
            Click me!
          </button>
        </div>
      </section>
    </main>
  );
};

export default EventBubblingDemo;

When you click the button, here's what happens:

  • The event first triggers on the button itself. The console logs: Event on BUTTON (button)
  • After the button's event handler finishes, the event "bubbles up" to its parent, the div. The console then logs: Event on DIV (div)
  • The event continues to bubble up to the section element. The console then logs: Event on SECTION (section)
  • Finally, it reaches the outermost ancestor in our component, the main element. The console then logs: Event on MAIN (main)

This bubbling happens automatically. Each parent element gets a chance to handle the event, regardless of whether the child elements have already processed it. The event handlers are executed from the innermost element outward.

Handling event propagation

In the standard event flow, handlers are naturally setup for the bubbling phase, where events ascend from their origin through the ancestral hierarchy.

However, this default journey can be altered with precision using the stopPropagation method. When invoked, this method acts as a barrier, halting the event's upward trajectory.

It effectively creates a containment zone, ensuring that the event remains localized. Consequently, any listeners for the same event type nestled in higher ranks of the DOM tree remain silent, their handlers untriggered.

This mechanism provides software engineers with fine-grained control over event propagation, allowing for targeted interaction handling within complex component structures.

Here is an example:

import React from 'react';

const EventBubblingDemo = () => {
  const logEvent = (e) => {
    console.log(`Event reached ${elementId}`);
    if (elementId === 'div') {
      console.log('Stopping propagation at div');
      e.stopPropagation();
    }
  };

  return (
    <main id="main" onClick={logEvent}>
      <section id="section" onClick={logEvent}>
        <div id="div" onClick={logEvent}>
          <button id="button" onClick={logEvent}>
            Click me!
          </button>
        </div>
      </section>
    </main>
  );
};

export default EventBubblingDemo;

We've created a logEvent function that returns an event handler. This handler logs which element the event reached and, if the element is div, stops the event propagation with call e.stopPropagation(). This prevents the event from bubbling up to parent elements after it reaches the div.

The Propagation Process: When you click the button, here's what happens:

  • The event triggers on the button: Event reached the button
  • It bubbles up to the div and logs the message: Event reached div
    Stopping propagation at div

The propagation stops at the div. The event never reaches the section or main elements. Once stopPropagation() is called, the event's journey ends.

Event Delegation

You can think of an event delegation in JavaScript as an efficient management paradigm for the DOM ecosystem.

Much like a skilled bandmaster who orchestrates a concert without direct involvement in each operation, event delegation leverages the natural bubbling of events through the DOM hierarchy.

This elegant approach places event listeners strategically on ancestral elements, creating a centralized point of control. Rather than attaching listeners to many individual elements, it capitalizes on the event's journey from its origin upwards through the DOM tree.

With this, developers can craft streamlined, scalable event-handling systems. This method not only enhances performance but also provides a flexible framework for managing dynamic content. It embodies the principle of "less is more" in event management, allowing for graceful handling of interactions across complex DOM structures with minimal overhead.

Let's take a look at this code example:

import React, { useState } from 'react';

const EventDelegationDemo = () => {
  const [items, setItems] = useState([
    { id: 1, text: 'Item 1' },
    { id: 2, text: 'Item 2' },
    { id: 3, text: 'Item 3' },
    { id: 4, text: 'Item 4' },
    { id: 5, text: 'Item 5' },
  ]);

  const [selectedItem, setSelectedItem] = useState(null);

  const handleClick = (event) => {
    const listItem = event.target.closest('li');
    if (listItem) {
      const itemId = parseInt(listItem.dataset.id);
      const clickedItem = items.find(item => item.id === itemId);
      setSelectedItem(clickedItem);
      console.log(`Clicked on: ${clickedItem.text}`);
    }
  };

  return (
    <div>
      <h2>Event Delegation Demo</h2>
      <ul onClick={handleClick}>
        {items.map(item => (
          <li key={item.id} data-id={item.id}>
            {item.text}
          </li>
        ))}
      </ul>
      {selectedItem && (
        <p>You selected: {selectedItem.text}</p>
      )}
    </div>
  );
};

export default EventDelegationDemo;

Here is what happens:

We use the useState hook to manage a list of items and the currently selected item. Then we define a single handleClick function that will handle clicks for all list items. This function is attached to the ul element, not individual li elements.

When a click occurs anywhere in the ul, the handleClick function is called. We use event.target.closest('li') to find the nearest li ancestor of the clicked element. This works even if you click on a child element within the li. If an li is found, we extract its data-id attribute to identify which item was clicked.

We render the list items dynamically using map. Each li is given a data-id attribute corresponding to its item id. We update the selectedItem state when an item is clicked. The selected item is displayed below the list.

Efficiency in this method is that only one event listener is needed, regardless of the number of list items. New items added to the list will automatically work with the existing event handler. It also reduces memory usage by not attaching individual listeners to each item and also centralizes event-handling logic in one place.

Conclusion

As we conclude this exploration of JavaScript's event ecosystem, you've learned about event handling and propagation. You've gained insights into the dual phases of event flow - capturing and bubbling. And how they orchestrate the journey of user interactions through the DOM.

Moreover, you've uncovered the power of event delegation, a paradigm that streamlines event management and enhances application performance.

Thanks for reading!