DEV Community

Valkoivo
Valkoivo

Posted on

Handshake Between Web Components

In the previous article, we made an important assumption: if the environment initializing a web component does not need to understand the structure of the transmitted data, it can simply link two components by passing the recipient the identifier of the source. This approach reduces coupling and makes the system more flexible.

However, this raises two more questions. First, are there any ways to avoid passing an identifier altogether? Second, how can a web component that receives a source identifier connect to the source itself? Let's try to answer them. As always, I promise it will be sufficiently tedious.

What Does It Mean to Link Two Web Components?

Linking two web components means establishing interaction between them, allowing them to exchange data or events.

For two web components to be linked, they must exist within the same environment where their accessibility is not restricted. This means that the components should either be in the same DOM scope or use mechanisms that allow them to bypass boundaries between different nesting levels. For example, if one component exists in the global DOM and the other is placed inside a Shadow DOM, direct access between them is impossible without using special APIs.

What other conditions must be met for two web components to interact effectively? It is crucial that their method of communication is predefined and agreed upon in advance. In other words, both components must understand the rules for exchanging data and events and follow the same logic for transmitting information. This can be achieved through custom events, property or attribute changes, the use of a global context, or the implementation of a dedicated state manager. If one component expects data to be transmitted in one way while the other provides it in a different way, the interaction will either not occur at all or result in incorrect system behavior.

The state of the components must be predictable. The data source should ensure that the information is available when requested, while the recipient must account for potential delays or the absence of data at the moment of the request. This is especially critical in asynchronous scenarios where data may become available only after some time.

Additionally, components must be able to respond correctly to state changes. This means that the data source must notify recipients when information is updated, and the recipient must correctly recompute its state in response to such updates. Without this, interaction will either be reduced to a one-time data transfer or lead to desynchronization, where some components work with outdated data while others use new but unconfirmed information.

That seems to cover everything. We have outlined all the requirements for linking two web components. These requirements can confidently be added to the criteria we previously defined for the environment necessary to initialize a component.

Notice that none of these requirements mention that the recipient must know the data source's identifier. Moreover, there is no indication that the components must interact directly.

Mediation in Component Communication

Two web components can be linked not only directly but also through an intermediary. In this case, their interaction is facilitated by an additional element that takes on some of the responsibilities related to managing data transfer. But how does this approach differ from direct interaction between components?

Examining this mechanism in detail reveals several key aspects. First, the intermediary assumes the data source’s responsibilities for handling requests from the recipient. This means that when the recipient requests information, it does not communicate with the original data source directly but rather with the intermediary, which either forwards the request or already possesses the necessary data.

Second, the intermediary is responsible for transmitting notifications about data changes. When the data source updates its information, it can notify the intermediary, which, in turn, informs the recipient.

However, the most important role of the intermediary is providing the recipient with information about the very existence and state of the data source. In a typical direct interaction scenario, the recipient must know where the source is and how to communicate with it. With an intermediary, this responsibility shifts: the intermediary monitors whether the source exists, what state it is in, and whether it is currently capable of providing data.

Essentially, the intermediary takes on the data source’s functions related not to the data itself but to the communication process.

From the recipient’s perspective, it does not matter which specific entity it interacts with. All that matters is that the entity has the required functionality for communication, meaning it conforms to a defined interface. If we replace the data source with an intermediary that provides the same interface, nothing changes from the recipient’s perspective. It continues sending requests and receiving data just as it would in direct interaction with the source. The only difference is that it now communicates with the intermediary instead of the original data source.

This leads to an important observation: when an intermediary is present, it effectively becomes the recipient’s primary data source. Everything we have discussed—and will discuss—regarding the connection between a source and a recipient also applies to the source-intermediary-recipient model. To keep things simple, I will use the term source to refer to any entity the recipient communicates with, whether it is the original data source or an intermediary.

At this point, you might wonder: “But does this entity even have to be a component?” The answer is no, not necessarily. While the main focus of this article is organizing communication between two web components, in a broader sense, we are discussing how a recipient obtains external data, regardless of its origin. Therefore, before moving forward, it makes sense to briefly discuss the nature of a data source.

What Is a Data Source?

Broadening the definition of a data source, we can conclude that a source is not necessarily another web component—it can be something more abstract. It might be a signal reflecting a state change, a centralized data store, or even a global object accessible throughout the system. But how important is it to precisely define what qualifies as a data source?

In reality, it’s not that critical. We have just established that our recipient component does not interact with a specific object but rather with an interface that provides the necessary functionality for communication. This means that any object conforming to the expected interface can automatically serve as a data source for the recipient.

Moreover, just because your web component is currently linked to another component does not mean the situation will remain unchanged in the future. At some point, you might need to connect the recipient component not to another web component but, for example, to a reactive signal or a global state. Naturally, it would be preferable if this change did not require major code modifications. After all, one of our key goals is to ensure components are universal and flexible enough to be used in different contexts without excessive adjustments.

Now, I’ll state something that may seem obvious but is no less important because of it. If the interfaces of the source and the recipient do not match—if one provides data in one format while the other expects it in another—an adapter will inevitably be required. An adapter aligns incompatible interfaces, ensuring proper interaction between different entities. We will explore adapters in more detail later, as it is an important topic.

For now, let’s take a closer look at what an interaction interface between a data source and a recipient can actually be.

Handshake Methods

Before two components can start interacting, they must first take the most important step—establishing initial contact. In the real world, communication between people begins when they introduce themselves to one another. In our case, this introduction can be understood as one entity notifying another of its existence. Without this step, no data exchange is possible, as the components simply wouldn’t know that they need to interact.

To describe this crucial process, I will use the metaphor of a handshake. Just as a handshake in real life symbolizes the beginning of communication between people, in our system, it serves as the starting point for component interaction. It allows participants to confirm that they exist in the same environment and are ready to exchange data.

But how exactly can this handshake be performed on a technical level? We have three main methods for establishing initial contact between components:

Using events. One component can emit an event announcing its existence, while another component can listen for this event and thus become aware of the first. This method is particularly useful when components are not tightly coupled but interact within a dynamic system.

Placing components in predictable locations within the DOM tree. If components are positioned in such a way that one can locate the other using DOM search methods (e.g., querySelector or closest), they can establish a connection simply by discovering each other in the document hierarchy. This approach works well when components are placed in expected locations and follow clear placement rules.

Using an identifier. As mentioned in the previous article, one component can simply pass its unique identifier to another, allowing it to be found. This method is useful when connecting remote elements that are not in the same DOM context but still need to interact in some way.

Each of these methods has its own characteristics, advantages, and limitations. To better understand their applications, let's examine them one by one.

Event-Based Handshake

What makes an event-based handshake unique compared to other methods of establishing communication between components? Its key distinction is that an event does not have to be directed at a specific object. Instead, a receiving component can subscribe to a certain type of event and intercept all such events that bubble up from nested elements. This enables more flexible interactions since the data source does not need to be aware of a specific receiver’s existence, and the receiver, in turn, does not need to know the exact location of the source.

At first glance, this method may seem to eliminate the need to pass an identifier for the data source into the component. And in a way, it does—the data source can generate events of a specific type, which the receiver can process without the source even knowing it exists. This approach allows data sources to be created dynamically without concern for how they will be linked to the receiver: the receiver will simply intercept the necessary events and take action accordingly. Moreover, this method abstracts away the identity of the data source itself. After all, the only requirement for the source is that it must be able to generate events of a certain type.

However, there is an important nuance here that should not be overlooked. While using events removes the need to pass an identifier for a specific source object, it introduces a different kind of strict dependency: now, the receiver must know the identifier of the event type that the data source will emit. In essence, we have simply replaced one identifier with another—perhaps without even realizing it.

For this interaction to work, both the source and the receiver must agree in advance on which specific events will be used for communication. But where and how should this agreement be established? The most straightforward approach is to document it. In practice, this is exactly what is usually done: a component’s documentation specifies which events it emits and which events it can handle.

But is this approach truly universal and flexible? Not entirely. Imagine that we need to change the receiver’s behavior so that it responds to a different event instead of the current one. If the event type is hardcoded, we would have to manually locate and replace it in the code. However, we have already discussed that hardcoded identifiers are not a best practice, as they make components harder to maintain and reuse.

Thus, events serve as a technical mechanism for transmitting information, but they do not solve the handshake problem itself. If a receiving component is to remain independent of the source, it must still be informed in advance—either of the source’s identifier or of the event type the source will use.

That said, there is another way to establish a connection between components that does not require passing identifiers. Let’s explore that next.

Using Addressing

As we have just established, it is possible to link two components without explicitly passing the data source’s identifier. However, despite this, the receiver still requires some information about the source; otherwise, it will be unable to establish a connection. Now, let’s explore whether a handshake can be performed not through the transfer of an identifier but by knowing the source’s location within the DOM tree.

Imagine you need to receive a package. If you know the sender’s identifier (such as their phone number), you can simply call them and arrange the delivery. This is equivalent to a scenario where the receiving component knows the data source’s identifier and communicates with it directly.

But there is another option. Instead of contacting the sender by phone, you can pick up the package yourself if you know the pickup location. In this case, the sender’s identifier is not needed—just the location where the package can be found. And if you have access to a directory, knowing the address might even allow you to determine the sender’s phone number, ultimately reducing the task to the previous scenario.

A similar logic can be applied to web components. If the receiver knows where to look for the source in the DOM, it can establish a connection without needing to know its identifier.

Of course, requiring the data source to always be in a strictly defined place within the DOM is impractical and often impossible. There are several reasons why such a rigid binding does not work:

Flexibility of the DOM structure. The structure of a web page is dynamic and is not always controlled by the developer of a specific component. Components can be added or removed depending on business logic, application state, or user actions.

Separation of responsibilities. One developer may be responsible for creating a web component, while another integrates it into the interface. A strict dependency on a specific location in the DOM would introduce additional challenges when using the component in different projects.

Instead of rigid positioning, relative placement of components can be used. The most common approach to organizing such interactions is by leveraging a parent-child hierarchy.

Receiver Inside the Source

The parent-child relationship is a widely used concept in organizing interactions between built-in components. This relationship can be observed in various places: for example, when structuring a list, where li elements are placed inside their parent ul, or when constructing a table, where tr rows and td cells follow a strict nested structure defined by HTML rules. Additionally, this model is commonly applied in other components that require a well-organized hierarchy for proper functioning.

This naturally raises the question: how effective is this model for organizing interactions between web components?

Let’s assume the receiver is located inside the source. In this case, the receiver can access its parent element using the parent property. This approach is intuitive for most developers since it has long been used in web development and does not introduce additional complexity. From the perspective of conceptual clarity and simplicity, this method aligns with the principles we previously discussed in earlier articles. However, one question remains: how well does this approach meet other important criteria?

Enforcing a strict nesting requirement means that two components become linked not only logically but also structurally. This implies that the developer must adhere to this rule when building the DOM tree and ensure that the document structure remains valid. Moreover, such a rigid constraint can significantly reduce an application’s architectural flexibility and make it more challenging to implement certain scenarios.

Let’s examine a practical example. Suppose we have a single data source and two receivers that need to obtain data from it. If we strictly follow the parent-child model, both receivers must be positioned close to the source—that is, at the same level of nesting within the parent. However, in real-world applications, the same data often needs to be displayed in different parts of the interface, and the corresponding components may be located far apart. In such cases, the nesting rule becomes a serious limitation.

One way to overcome this limitation is through event-based communication. Suppose our data receiver is not inside the source but is instead several levels deeper in the document structure. In this case, the receiver can send an event that bubbles up the DOM tree, notifying the data source of its presence. This can be achieved using the bubbles property of the event object, which allows information to propagate through the hierarchy of parent elements.

However, even with this approach, some challenges remain. While the components are now less tightly coupled, a dependency still exists. The developer still cannot freely reposition either the source or the receiver without considering their relative placement. This means that whenever the DOM structure changes—whether due to product evolution or feature expansion—the entire document organization may need to be revisited. In some cases, this modification might require additional specialists, such as front-end developers working on layout and design.

Even this does not exhaust all potential complexities. Let’s consider another scenario involving dynamic data sources. Imagine we have two different data sources and a single receiver. For example, suppose we have a chart component that visualizes incoming data. This chart should be able to receive data from either a remote server or a local file uploaded by the user. Ideally, the user should have the ability to select the data source dynamically.

One possible implementation involves creating two separate source components: one for retrieving data from a server and another for loading data from a file. In an ideal system, the chart should be able to switch between these sources in real-time. However, if our architecture enforces a strict parent-child model, we would have to nest the data receiver inside one source and then somehow re-nest it into another source when switching.

The situation becomes even more complex if data sources need to be added dynamically. For instance, as the product evolves, new types of sources may need to be supported. In such cases, developers face the challenge of restructuring the entire component hierarchy to accommodate these changes.

Despite its simplicity and intuitive nature, the parent-child model imposes significant limitations when used with web components. In many cases, this model lacks the flexibility needed to handle dynamic component interactions effectively. While it can be useful for certain well-structured relationships, it is not always the best choice for systems that require dynamic or loosely coupled communication between components.

Source Inside the Receiver

Now, let's consider a situation opposite to the one we discussed earlier. Imagine that the data source is located inside the receiver.

First of all, based on our previous observations, we can immediately point out an example that highlights the drawbacks of this approach. Let’s examine a case where we have two data receivers and only one source. Obviously, if the data source is embedded within one of the receivers, the second receiver loses access to it. This, in itself, is already a strong argument against this approach, as it introduces unnecessary restrictions.

However, a logical question arises: if this setup has such obvious disadvantages, why is event bubbling used in web development? After all, the bubbling mechanism specifically addresses the problem of passing information from nested elements to their parents.

The answer lies in the fact that technological solutions are not always designed to completely eliminate a problem in all its possible forms. Sometimes, their primary goal is simply to make solving the most common specific cases easier. Web technologies often evolve in a way that provides developers with convenient tools for handling typical tasks while leaving more complex and rare scenarios unresolved, requiring additional effort.

Let’s analyze situations where nesting sources and receivers within each other actually complicates system architecture. The main difficulty arises when a single data source needs to serve multiple receivers, or conversely, when a single receiver needs to interact with multiple sources.

But what if we know in advance that a data source will never be used anywhere except within a strictly defined receiver? Would nesting the source inside the receiver create any problems in that case?

Consider a concrete example. Suppose we have a button that triggers some action—such as a button within a dialog window. In this case, the button acts as the information source, while the dialog window serves as the receiver. The button transmits data containing a command to the window, and the window itself decides what to do with this data. Meanwhile, all other components in the system don’t need to be aware of the button’s existence since they don’t interact with it in any way.

In this situation, the proposed approach turns out to be not only applicable but even beneficial. Placing the source inside the receiver reduces component coupling because the window does not need to be aware of the specific buttons inside it. This, in turn, increases system flexibility, allowing for dynamic creation and modification of button sets without requiring changes to the window's logic.

However, even in this scenario, there is one nuance that cannot be overlooked. How exactly will the button transmit information to the dialog window? An obvious solution is to use events, but in doing so, the button and the window must agree on the type of event being transmitted. This means that while coupling is reduced in one aspect, dependency still remains in another: the button needs to know what event to generate, and the window needs to know what event to listen for and how to handle it.

Thus, even when using source nesting within the receiver to improve usability for a specific component, there remains a need for an agreement between them regarding the interaction format. This brings us back to the necessity of passing an event type identifier to the button.

How Bad Is It to Use an Identifier?

Summing up everything discussed above, we can draw an important conclusion: if there is a relatively simple and efficient way to organize interaction between two web components without tightly coupling them to their position in the DOM structure, it makes sense to use it.

However, another equally important conclusion follows from everything stated earlier: no matter how much we try, we cannot completely avoid the need to pass some kind of identifier that allows the source and the receiver to "recognize" each other and establish a connection. Therefore, we should examine what compromises and potential downsides come with using identifiers for communication between web components.

At first glance, using an identifier may not seem like the most obvious or intuitive way to establish interactions between elements. Moreover, observing the various alternative approaches that developers often try to come up with for solving similar tasks, I have concluded that using an identifier is not only unintuitive for many but also poses certain difficulties for a significant portion of developers. This may be because many are accustomed to thinking in terms of the hierarchical DOM structure and consider element nesting to be the natural and only correct way to organize interactions.

To determine whether an identifier is indeed an unintuitive way to establish connections, let's look at examples of existing built-in HTML elements that use this mechanism.

As is well known, the label element can be linked to an input field using the for attribute. This attribute must contain the id of the corresponding input, allowing the user to click on the label and automatically focus on the associated input field.

<label for="username">Name:</label>
<input type="text" id="username">
Enter fullscreen mode Exit fullscreen mode

Similarly, the output element, which is used to display computed values, can be linked to one or more input elements via the for attribute. This makes it clearer which input fields influence the computed result.

<form oninput="result.value=parseInt(a.value)+parseInt(b.value)">
  <input type="number" id="a" value="0"> +
  <input type="number" id="b" value="0"> =
  <output name="result" for="a b">0</output>
</form>
Enter fullscreen mode Exit fullscreen mode

The datalist element provides users with a list of predefined options associated with a specific input. The connection between them is established through the list attribute.

<input list="browsers">
<datalist id="browsers">
  <option value="Chrome">
  <option value="Firefox">
  <option value="Edge">
</datalist>
Enter fullscreen mode Exit fullscreen mode

This list could go on for quite a while, as the mechanism of explicitly specifying relationships between elements using identifiers is widely used even in standard HTML components. Thus, we can conclude that this approach is neither unusual nor rare—on the contrary, it has long been a well-established, convenient, and flexible way of linking elements in web development.

But here arises a logical question: if this approach is indeed so convenient and widespread, why do many developers intuitively prefer nesting and event bubbling for establishing connections?

Therefore, before drawing final conclusions, we need to verify whether using an identifier truly simplifies component behavior.

Obtaining a Reference to the Data Source

When we talk about a data source identifier that is passed to the receiver, it is important to remember that an identifier is merely a string used to designate an element—it does not provide direct access to it. In other words, having an identifier does not mean we can call the source’s methods or interact with it directly. To do that, we need a reference to the actual object we want to work with.

This raises the question: how can we obtain this reference if all we have is an identifier? Fortunately, standard web development tools offer a well-known and widely used way to find elements in the DOM. For instance, we can use methods like document.getElementById(), document.querySelector(), or document.querySelectorAll() to locate an element by its identifier. This is a common practice when working with various elements on a page.

However, as with any approach, there are potential situations where this method may not work.

With declarative initialization, there are no issues. Even if the source itself has not yet been fully initialized, as long as its tag is present in the DOM tree, the receiver can easily obtain a reference to it using document.getElementById().

<my-source id="data-source"></my-source>
<my-component source_id="data-source"></my-component>

<script>
class MyComponent extends HTMLElement {
    connectedCallback() {
        let source_id = this.getAttribute("source_id");
        this._source = this.setSource(
             document.getElementById(source_id)
        );
    }
    setSource(source){
        this._source = source;
    }
}
customElements.define("my-component", MyComponent);
</script>
Enter fullscreen mode Exit fullscreen mode

In the case of dynamic initialization, the situation can be different. If the receiver is inserted into the DOM before the source, it will not be able to immediately obtain a reference to the source using document.getElementById(), since the source simply does not exist yet.

<script>
class MyComponent extends HTMLElement {
    connectedCallback() {
        let source_id = this.getAttribute("source_id");
        this._source = this.setSource(
             document.getElementById(source_id)
        );
    }
    setSource(source){
        this._source = source;
    }
}
customElements.define("my-component", MyComponent);

const receiver = document.createElement("my-component");
document.body.appendChild(receiver);

const source = document.createElement("my-source");
document.body.appendChild(source);
</script>
Enter fullscreen mode Exit fullscreen mode

Since we are working with dynamically created elements, we have another approach available. When creating the source, we can store a reference to it and pass this reference directly to the receiver.

<script>
class MyComponent extends HTMLElement {
    connectedCallback() {
        let source_id = this.getAttribute("source_id");
        this._source = this.setSource(
             document.getElementById(source_id)
        );
    }
    setSource(source){
        this._source = source;
    }
}
customElements.define("my-component", MyComponent);

const receiver = document.createElement("my-component");
document.body.appendChild(receiver);

const source = document.createElement("my-source");
document.body.appendChild(source);
receiver.setSource(source);
</script>
Enter fullscreen mode Exit fullscreen mode

As we can see, working with data source identifiers is a straightforward and intuitive process. In most cases, if the data source is created declaratively, no issues arise at all. With dynamic initialization, the order in which elements are inserted into the DOM must be taken into account, but this is not a significant challenge. Thus, we can confidently say that, at least for establishing an initial connection ("handshake") between two web components using an identifier, a simple and predictable approach is sufficient.

The Death of a Reference

When discussing mechanisms for working with identifiers, there is another crucial aspect that developers often overlook when designing web components and interaction systems between elements: proper reference management when objects are removed.

At first glance, this might not seem like a significant issue—after all, browsers have a garbage collector that automatically frees memory occupied by unused objects. However, for this mechanism to function correctly, developers must follow certain rules. Ignoring these rules can lead to memory leaks, unexpected component behavior, and difficult-to-debug errors.

If a receiver holds a reference to a data source, and one of them is later removed, different scenarios can arise depending on how references are handled and the type of object being deleted.

If the receiver is removed while the source continues to exist, memory leaks typically do not occur. The receiver disappears, and its references to the source are no longer used, allowing the garbage collector to free up memory. However, if the source maintained a reference back to the receiver (e.g., via a subscription or callback), the memory may not be freed until this connection is explicitly broken.

If the source is removed while the receiver still holds a reference to it, the source object will not be garbage collected, even if it is no longer used elsewhere in the program. This can lead to memory leaks, especially if the source consumes significant resources.

To prevent such issues, developers can use weak references (WeakRef) or unsubscribe mechanisms (e.g., explicitly removing event listeners). A good practice is also to break all unnecessary references in disconnectedCallback.

This raises an important question: if we establish connections between components using references, who should be responsible for managing them? Should the developer using the web component handle this manually? Clearly, it’s better to eliminate that burden. The correct approach is for the component itself to manage references properly, ensuring that it can be used without requiring the developer to worry about these details.

Summary

Let’s summarize the key takeaways from our discussion.

Regardless of the chosen approach for establishing a "handshake" between web components, the use of an identifier is unavoidable. This identifier plays a critical role, as it enables retrieving a reference to the data source or initiating an event-based communication mechanism.

An adapter will likely be necessary when transferring data between components. This is especially relevant when the source and receiver expect data in different formats or operate based on different principles.

It must be possible to change the identifier dynamically. In real-world applications, the data source may change, meaning the receiver must be able to adapt without losing functionality.

Web components may be removed from the DOM during operation. The system must be designed to handle cases where either the source or the receiver ceases to exist. Failing to account for this can lead to memory leaks or lingering references to nonexistent elements.

This behavior is not tied to any specific source or receiver implementation. It can be generalized and applied to various web components, making it worth formalizing into a reusable solution.

In the next article, we will begin exploring how to implement such behavior.

Looking ahead, this is precisely the behavior I have implemented in the KoiCom library.

KoiCom documentation
KoiCom github

You can use this solution in your projects to simplify web component interactions and avoid numerous potential pitfalls.

Top comments (2)

Collapse
 
dannyengelman profile image
Danny Engelman

Too much text

Collapse
 
valkoivo profile image
Valkoivo

Thank you. I did my best. Though, I’m still far from surpassing the Iliad.