Forem

Valkoivo
Valkoivo

Posted on

An Example of Data Exchange Between Web Components

In the previous articles, I devoted significant attention to the theoretical aspects related to organizing the interaction of web components. We thoroughly examined various approaches to building architecture, discussed potential challenges, and explored ways to overcome them. One of the key points was ensuring a convenient and flexible method for creating web components in such a way that they could be easily integrated into a project or tested in an isolated environment with minimal effort.

Now that the theoretical foundation has been established, it's time to move from theory to practice. In this article, I will attempt to implement a web component that can connect to other components and receive data from them.

Let's Examine the Data Source

Before we start creating our web component, it's essential to carefully consider what kind of data source it will interact with. This data source can be almost any existing web component, regardless of its purpose or internal implementation.

Let’s assume that this component wasn’t developed by you. This imposes certain limitations and naturally creates a desire to minimize interference with its source code. Ideally, you wouldn’t want to waste time delving deeply into someone else's logic, rewriting it, or adapting it to your needs.

What does this imply? First of all, the data source must be as isolated as possible from the data recipient. It shouldn’t know anything about the entities that will connect to it, nor should it be required to support any specific data transfer protocols. When it was created, the developers might not have envisioned that it would ever be used to provide data to external components.

Nevertheless, despite these limitations, we still have basic requirements for the data source. First and foremost, it must contain the data we want to use. Let’s assume it’s some object with data.

<!DOCTYPE html>
<html>
<body>
    <my-provider id="provider_id"></my-provider>
</body>
<script>
class MyProvider extends HTMLElement {
    constructor(){
        super();
        this._data = {
            value: 'Some initial value'
        };
    }
}
customElements.define('my-provider', MyProvider);
</script>
</html>
Enter fullscreen mode Exit fullscreen mode

Of course, this implementation is not sufficient. If we want the data source to not only exist but also actively notify about changes, we need to implement an event-triggering mechanism. This approach will turn the component into a proactive source, responding to data changes.

It’s important to note that if the data source was created without our involvement, any events it may generate were likely not intended to notify external recipients. Most often, such events are designed for the internal purposes of the component itself, such as updating its visual state. However, for our needs, this doesn’t matter — what’s important is simply the existence of an event signaling a change in data.

class MyProvider extends HTMLElement {
    constructor(){
        super();
        this._data = {
            value: 'Some initial value'
        };
    }
    _createEvent(){
        this._event = new CustomEvent('my-provider-data-changed');
    }
    _announceDataReady(){
        this.dispatchEvent(this._event);
    }
    _announceDataChanged(){
        this.dispatchEvent(this._event);
    }
    connectedCallback() {
        this._createEvent();
        this._announceDataReady();
    }
    changeData(new_value){
        this._data.value = new_value;
        this._announceDataChanged();
    }
}
Enter fullscreen mode Exit fullscreen mode

And yet, something is missing. The data source should provide a way to retrieve data, but the original component developer did not implement such an interface. This creates some inconvenience, but fortunately, we can fix the situation without modifying the original code.

To do this, we will create our own subclass that extends the existing component and adds the necessary method.

<my-provider id="provider_id"></my-provider>

<script>
class MyProvider extends HTMLElement {
    // ...
}

const CustomInterfaceExposable = Sup => class extends Sup {
    retrieveDataObject(){
        return this._data;
    }
}
class MyCustomProvider extends CustomInterfaceExposable(MyProvider) {
}
customElements.define('my-provider', MyCustomProvider);
</script>
Enter fullscreen mode Exit fullscreen mode

At this stage, we can consider the data source ready for use. We have successfully adapted the third-party component to meet the needs of our project by adding just one method with a name that was already known to us. Now, this method will be used to access the data object, which is the core value for us.

Note: It is important that we are well-acquainted with the interface of the data object itself, as this is what we will be working with, without being distracted by the behavior specifics of the data source.

Data Receiver

Now that we have defined the data source and adapted it to our needs, it’s time to consider the second key element — the data receiver. This is the component we will be creating. Its primary task is to receive data from the source using a unified interface, which will provide flexibility and standardization across the project.

In other words, the interface implemented in the data source must be supported in the receiver as well. This ensures that the receiver can correctly interact with any source that conforms to this interface.

const CustomInterfaceConsumable = Sup => class extends Sup {
    _retrieveProviderDataObject(provider){
        return provider.retrieveDataObject();
    }
}

class MyConsumer extends CustomInterfaceConsumable(HTMLElement) {
    constructor(){
        super();
        this._data = {
            value: 'Another initial value'
        };
    }
    changeData(new_value){
        this._data.value = new_value;
        console.log('The consumer is changed:' + this._data.value);
    }
}
Enter fullscreen mode Exit fullscreen mode

One key consideration when developing the data receiver is the freedom of its placement within the DOM structure. Why is this important? The fact is that the DOM structure is often developed by a separate specialist — a layout designer — and we don’t want to impose rigid placement requirements on them. This would add unnecessary restrictions and complicate the overall development process.

Our goal is to ensure that the connection between the data source and receiver doesn’t depend on their relative positioning in the DOM tree. Ideally, the receiver can be placed anywhere: inside the source, outside it, on the same nesting level, or deeper. Furthermore, we want to support the ability to connect multiple receivers to a single source. To achieve this flexibility, we will use an identifier-based connection mechanism.

<my-provider id="first_provider_id"></my-provider>
<my-consumer provider_id="first_provider_id"></my-consumer>
<my-consumer provider_id="first_provider_id"></my-consumer>
<my-consumer provider_id="first_provider_id"></my-consumer>

<my-consumer provider_id="second_provider_id">
    <my-provider id="second_provider_id"></my-provider>
</my-consumer>

<my-provider id="third_provider_id">
    <my-consumer provider_id="third_provider_id"></my-consumer>
</my-provider>
Enter fullscreen mode Exit fullscreen mode

As seen in the example, the data receiver can be either at the same level as the source, inside it, or vice versa — the source can be contained within the receiver’s structure. By using identifiers, all receivers can easily find the appropriate source and retrieve data from it.

As we mentioned in the previous article, simply passing the identifier is not enough. We need the receiver not only to store the source’s identifier but also to be able to convert it into a direct reference to the data source itself. Without this, interaction would be impossible.

Note: It’s important that the behavior we implement is universal. We want to create a mechanism that can be applied to any component, turning it into a data receiver without making complex changes to its internal code.

const ProviderConnectable = Sup => class extends Sup {
    constructor(){
        super();
        this.provider_id = this.getAttribute("provider_id");
    }
    connectedCallback() {
        this._provider = this._identifyProvider();
        this._onProviderIdentified();
    }
    _identifyProvider(provider_id){
        return document.getElementById(
            this.provider_id
        );
    }
    _onProviderIdentified(){
        // Do something to initialize
    }
}

class MyConsumer extends CustomInterfaceConsumable(
    ProviderConnectable(
        HTMLElement
    )
) {
    _onProviderIdentified(){
        let data_object = this._retrieveProviderDataObject(this._provider);
        // ...
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Check for Initialization

When working with web components, a common problem arises: the initialization of the data source and receiver doesn’t always happen synchronously. It’s entirely possible that by the time the receiver is initialized, the source may not be ready yet. Although the receiver can obtain a reference to the source, the object itself might not have the necessary methods, such as retrieveDataObject. This can lead to errors or incorrect behavior in the application.

To avoid such situations, we need to implement a universal mechanism to check the readiness of the data source.

const ProviderConnectable = Sup => class extends Sup {
    // ...
    _isProviderReady(){
        return this._provider && (typeof(this._provider.connectedCallback) === function);
    }
    // ...
}

class MyConsumer extends CustomInterfaceConsumable(
    ProviderConnectable(
        HTMLElement
    )
) {
    // ...
    _onProviderIdentified(){
        if(this._isProviderReady()){
            let data_object = this._retrieveProviderDataObject(this._provider);
            this.changeData(data_object.value);
        }
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

However, this approach only masks the problem, hiding the initialization error. We need to do more than just check the source's readiness — we must ensure correct interaction when initialization happens at different times. It’s important not only to determine readiness but also to organize a mechanism in which the receiver can wait for the source to be ready.

From the source code, we know that when initialization is complete, it triggers an event. By using this, we can create a universal notification mechanism.

Event Type Identifier

For the data receiver to correctly respond to the event signaling the completion of the data source initialization, it’s not enough to simply subscribe it to the event. The receiver must know exactly which event it needs to react to.

The main problem is that the receiver cannot know in advance the event identifier for the event that signals the completion of the data source initialization. If the data source is not yet initialized, it naturally cannot provide methods or properties to retrieve the event identifier. This creates a circular dependency: the receiver needs the event identifier to subscribe to it, but it can only obtain it from the already initialized source.

The most universal and straightforward solution is to pass the event identifier through a component attribute.

const ProviderConnectable = Sup => class extends Sup {
    constructor(){
        // ...
        this.provider_event = this.getAttribute("provider_event");
    }
    connectedCallback() {
        // ...
        this._subscribeToProviderEvent();
    }
    disconnectedCallback() {
        this._unsubscribeFromProviderEvent();
    }
    _subscribeToProviderEvent(){
        this._onProviderDataChangedBinded = this._onProviderDataChanged.bind(this);
        this._provider.addEventListener(
            this.provider_event,
            this._onProviderDataChangedBinded
        );
    }
    _unsubscribeFromProviderEvent(){
        this._provider.removeEventListener(
            this.provider_event,
            this._onProviderDataChangedBinded
        );
    }
    _onProviderDataChanged(event){
        // Do something with changed data
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Then, the component declaration in the DOM will look as follows:

<my-provider id="provider_id"></my-provider>
<my-consumer id="consumer_id" provider_id="provider_id" provider_event="my-provider-data-changed"></my-consumer>
Enter fullscreen mode Exit fullscreen mode

Handlers

After we’ve created the behavior for the components, which universally links them together and allows them to be easily and declaratively declared in the DOM, there’s just one important task left — enabling the receiver to use the data provided by the source.

class MyConsumer extends CustomInterfaceConsumable(
    ProviderConnectable(
        HTMLElement
    )
) {
    constructor(){
        super();
        this._data = {
            value: 'Another initial value'
        };
    }
    _onProviderIdentified(){
        if(this._isProviderReady()){
            let data_object = this._retrieveProviderDataObject(this._provider);
            this.changeData(data_object.value);
        }
    }
    _onProviderDataChanged(event){
        if(this._isProviderReady()){
            let data_object = this._retrieveProviderDataObject(this._provider);
            this.changeData(data_object.value);
        }
    }
    changeData(new_value){
        this._data.value = new_value;
        console.log('The consumer is changed:' + this._data.value);
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, our behavior gives the component the ability to implement two event handlers, inside which it gets a reference to the data object of the source, and all subsequent work is done with this data object. This is exactly what we were aiming for.

Summary

We have now completed the development of a mechanism that makes it easy and efficient to connect any component to a data source. We’ve created a behavior that can be applied to any web component element to allow it to integrate with other components acting as data sources. This solution offers several key advantages.

First, thanks to this behavior, the component can connect to the data source without needing to modify the JavaScript code itself. That is, we don't restrict the developer in creating components and integrating them into the project. This approach avoids unnecessary dependency on a specific implementation and provides flexibility in using the component. The component easily adapts to external conditions and can be embedded in the project without additional effort.

Second, the component can be declared both declaratively and dynamically. This means we can use standard HTML markup to define the relationships between components or create components on the fly using JavaScript, depending on the needs of the project.

Additionally, the solution we developed does not require changes to the DOM structure. Components can be placed anywhere within the DOM tree, whether near the data source or in another part of the document. This is an important advantage, as DOM structures are often built with various layout and interface requirements in mind, and we don’t want to restrict component placement.

What’s more important, our approach allows components to work independently of the order in which they are initialized. The data source and receiver can be initialized in any order, and the system will always work correctly, ensuring a reliable connection between the components, even if the data source isn’t ready yet while the receiver is already initialized.

To ensure the stability and functionality of this approach, you can test it in a live example available on the JSFiddle platform at the following link:

JSFiddle Web Components Data Exchange

This approach is used within the KoiCom library, which I developed specifically to simplify the creation of user interfaces.

KoiCom documentation
KoiCom github

The library provides a set of tools for quick and efficient work with web components, enabling developers to easily build interactions between various parts of the interface while minimizing the amount of code and effort required to set up the system.

Top comments (0)