javascripthtmlweb-componentcustom-elementnative-web-component

How to communicate between Web Components (native UI)?


I'm trying to use native web components for one of my UI project and for this project, I'm not using any frameworks or libraries like Polymer etc. I would like to know is there any best way or other way to communicate between two web components like we do in angularjs/angular (like the message bus concept).

Currently in UI web-components, I'm using dispatchevent for publishing data and for receiving data, I'm using addeventlistener. For example, there are 2 web-components, ChatForm and ChatHistory.

// chatform webcomponent on submit text, publish chattext data 
this.dispatchEvent(new CustomEvent('chatText', {detail: chattext}));

// chathistory webcomponent, receive chattext data and append it to chat list
this.chatFormEle.addEventListener('chatText', (v) => {console.log(v.detail);});

Please let me know what other ways work for this purpose. Any good library like postaljs etc. that can easily integrate with native UI web components.


Solution

  • Updated Answer (2025) Incorporating comments

    If you think of Web Components as being like built-in HTML elements such as <div> or <audio>, you’ll notice that they do not communicate directly with each other. Similarly, custom Web Components should be designed to be independent and reusable, without hard dependencies on sibling components.

    Best Practice: Use the Parent Component as a Mediator

    Instead of allowing direct sibling-to-sibling communication, the parent component (or the main app logic) should handle interactions between them. The recommended approach is:

    1. Component A dispatches an event when something happens.
    2. The parent listens to the event and then updates Component B by calling a method, setting a property, or updating an attribute.

    This method keeps components decoupled, making them easier to reuse and swap out without rewriting sibling logic.


    Example: Parent Mediating Between Two Web Components

    class ChatForm extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: "open" });
    
        this.shadowRoot.innerHTML = `
          <input type="text" id="input">
          <button id="send">Send</button>
        `;
    
        const input = this.shadowRoot.querySelector("#input");
        this.shadowRoot.querySelector("#send").addEventListener("click", () => {
          const chatText = input.value;
          this.dispatchEvent(new CustomEvent("chatText", {
            detail: chatText,
            bubbles: true, // Allow event to reach the parent
            composed: true
          }));
          input.value = '';
          input.focus();
        });
      }
    }
    
    class ChatHistory extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: "open" });
    
        this.shadowRoot.innerHTML = `<ul id="history"></ul>`;
      }
    
      addMessage(message) {
        const li = document.createElement("li");
        li.textContent = message;
        this.shadowRoot.querySelector("#history").appendChild(li);
      }
    }
    
    customElements.define("chat-form", ChatForm);
    customElements.define("chat-history", ChatHistory);
    
    // Parent component or external script manages communication
    document.addEventListener("DOMContentLoaded", () => {
      const chatForm = document.querySelector("chat-form");
      const chatHistory = document.querySelector("chat-history");
    
      chatForm.addEventListener("chatText", (event) => {
        chatHistory.addMessage(event.detail);
      });
    });
    <chat-form></chat-form>
    <chat-history></chat-history>

    Key Takeaways


    Why Avoid Direct Sibling Communication?

    Directly allowing one component to modify or interact with another sibling creates tight coupling. If ChatForm had a direct reference to ChatHistory, it could only work in environments where both components exist together, reducing flexibility.

    A great analogy shared in the comments by Danny '365CSI' Engelman explains event-based thinking:

    If I run a classroom, I don't know how many students will come today or when. But I have a strict rule: I wait two minutes after the last person entered, then I lock the door. This teaches them event-based programming.

    Similarly, Web Components should emit events and let a higher-level controller (parent component or global event system) decide how to respond.


    How Native HTML Elements Handle Communication

    The <label> Pattern: Loose Coupling Through Indirection

    The <label> element does not interact directly with inputs but instead refers to them indirectly using the for attribute.

    <label for="username">Enter your name:</label>
    <input type="text" id="username">
    

    Instead of needing to manipulate the <input> directly, clicking the <label> automatically focuses the input, allowing decoupled interaction.

    Applying This to Web Components

    A <custom-label> could follow the same pattern:

    class CustomLabel extends HTMLElement {
      connectedCallback() {
        this.addEventListener("click", () => {
          const targetId = this.getAttribute("for");
          const target = document.getElementById(targetId);
          if (target) {
            target.focus();
          }
        });
      }
    }
    
    customElements.define("custom-label", CustomLabel);
    <custom-label for="user-input">Enter your name:</custom-label>
    <input id="user-input"></input>

    This ensures loose coupling, where the label does not need to know details about the input component.


    The <form> Pattern: Parent Collecting Data from Children

    A <form> does not interact with its children directly but instead queries them dynamically at submission time.

    <form>
      <input type="text" name="username">
      <input type="password" name="password">
      <button type="submit">Submit</button>
    </form>
    

    At submission time, it collects values from child inputs without needing to know their structure in advance. The process is fairly complicated in the browser and the examples are just to show one way for a parent to know about its children.

    Applying This to Web Components

    A <custom-form> can query its child fields dynamically:

    class CustomForm extends HTMLElement {
      connectedCallback() {
        this.querySelector("#submit-btn").addEventListener("click", () => {
          const data = {};
          this.querySelectorAll("[name]").forEach((field) => {
            data[field.getAttribute("name")] = field.value;
          });
    
          console.log("Form submitted:", data);
          this.dispatchEvent(new CustomEvent("formSubmit", { detail: data }));
        });
      }
    }
    
    customElements.define("custom-form", CustomForm);
    <custom-form>
      Name: <input name="username"><br>
      Password: <input name="password"><br>
      <button id="submit-btn">Submit</button>
    </custom-form>

    This is a basic example and probably should not be done exactly like this.

    This ensures modular form components, where new fields can be added without modifying the form logic.


    Alternative Approaches for Large-Scale Applications

    For advanced cases where multiple components must communicate across different parts of the UI, you may need an event bus.

    Using BroadcastChannel (Works Across Tabs)

    If components need to communicate globally, BroadcastChannel is an option:

    class ChatForm extends HTMLElement {
      #channel = new BroadcastChannel("chat");
    
      constructor() {
        super();
        this.attachShadow({ mode: "open" });
    
        this.shadowRoot.innerHTML = `<input type="text" id="input">
        <button id="send">Send</button>`;
        const input = this.shadowRoot.querySelector("#input");
    
        this.shadowRoot.querySelector("#send").addEventListener("click", () => {
          this.#channel.postMessage(input.value);
          input.value = '';
          input.focus();
        });
      }
    }
    
    class ChatHistory extends HTMLElement {
      #channel = new BroadcastChannel("chat");
    
      constructor() {
        super();
        this.attachShadow({ mode: "open" });
    
        this.shadowRoot.innerHTML = `<ul id="history"></ul>`;
    
        this.#channel.addEventListener("message", (event) => {
          this.addMessage(event.data);
        });
      }
    
      addMessage(message) {
        const li = document.createElement("li");
        li.textContent = message;
        this.shadowRoot.querySelector("#history").appendChild(li);
      }
    }
    
    customElements.define("chat-form", ChatForm);
    customElements.define("chat-history", ChatHistory);
    <chat-form></chat-form>
    <chat-history></chat-history>

    You can further enhance loose coupling by allowing the BroadcastChannel name to be defined as an attribute, enabling components to dynamically subscribe to different communication channels without modifying their internal logic. This approach makes components more flexible and configurable, allowing them to be reused in different contexts without hardcoded dependencies.


    By following these best practices, your Web Components will be decoupled, reusable, and maintainable, ensuring they work well in both small projects and large-scale applications.