DEV Community

Valkoivo
Valkoivo

Posted on

Methods for Initializing Web Components with External Data

In the previous article, we discussed the criteria that the environment initializing a web component should meet. Now, we face the next question: which method of initializing a web component should be chosen if external data needs to be passed to it?

In this article, we will explore the main methods for initializing a web component, discussing their advantages and disadvantages using the criteria from the previous article. I promise it will be more tedious than usual.

Methods of Passing Data During Initialization

There are many different ways to initialize a web component, each with its own features and nuances. Classifying these methods strictly and unambiguously is quite difficult, as in practice, they rarely appear in their pure form. More often, developers combine various approaches, selecting the most convenient or efficient solutions for a specific task. However, to have a starting point in this variety, let’s begin by distinguishing based on a key factor: whether the server is involved in the process of initializing the web components.

If the server is involved, it not only chooses the specific components and data with which they will work, but also controls their codebase. In this case, one can say that the server generates the final component code. To do this, it may use various tools, such as templating engines, transpilers, optimizers, and minifiers. As a result, the browser ends up with pre-processed code that has gone through all the necessary preparation stages. In essence, this code is similar to a compiled program, and therefore, the programmer working in the browser typically does not need to understand the details of its structure.

It is important to mention that debugging such code in the browser can sometimes be a real challenge. Developers working with server-oriented frameworks often struggle to figure out what is actually happening in the code they receive after all these transformations. In such cases, analyzing the code often leads to confusion and requires a lot of brainpower.

But let's return to the features of the server-side approach. Since the server has full control over the code, it is not obligated to produce code with the characteristics of good code. In other words, the server does not need to follow the component initialization rules that programmers adhere to for readability and usability. Compiled code is subject to entirely different requirements, focused on rendering efficiency and resource optimization.

When it comes to passing data in such an approach, any methods, even the most rudimentary ones, can be applied. Here is a simple example illustrating how data can be hardcoded for a web component directly on the server:

<?php
$hardcodedTitle = "Hello!";
?>
<!DOCTYPE html>
<head>
    <script>
        class MyComponent extends HTMLElement {
            connectedCallback() {
                this.innerHTML = "<?=$hardcodedTitle?>";
            }
        }
        customElements.define("my-component", MyComponent);
    </script>
</head>
<body>
    <my-component></my-component>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

In this example, the server pre-inserts data into the component before sending the HTML code to the browser. This approach, of course, goes against the principles of good code, but it is fully functional. In this case, the browser does not need to reuse or extend the component or interact with it in any way other than displaying it on the page. It simply receives the ready-made instance and uses it as is.

From this, we can conclude that with server-side initialization, web components are not strictly necessary. The server can form the interface using standard HTML elements or any other available tools. In this context, web components are no more than an additional layer of abstraction, which doesn’t always make sense.

But when we talk about web components, we are not just interested in their existence but in the ability to operate with them as independent, autonomous units that are understandable not only to the browser but also to the developer. That’s why server-side initialization methods are beyond the scope of our discussion. Instead, we will focus on those methods that allow web components to be initialized directly in the browser.

This means that in our case, the application will run in the client environment, initializing components in the DOM at runtime. In this article, we will cover two such methods. The first method involves creating a component and then placing it in the DOM using JavaScript code — we will refer to this as dynamic initialization. The second method involves pre-defining the HTML tag for the component in the markup — we can call this declarative initialization. Both approaches have their pros and cons, and we will examine their features in detail later.

Dynamic Initialization

Dynamic initialization of a web component is carried out as follows:

const my_component = document.createElement('my-component');
my_component.some_property = 'Some data';
document.body.appendChild(my_component);
Enter fullscreen mode Exit fullscreen mode

In this code, you can see that setting values for a web component’s properties occurs after the constructor is called and before the component is added to the DOM. This order is due to the fact that the constructor is intended solely for basic initialization, meaning that attributes, internal elements of the component, or even parameters for the constructor should not be accessed within it. Although the web component specification does not prohibit the explicit use of parameters in the constructor, practice shows that this approach is not recommended. Data should be passed to the component only after the constructor has finished.

In all other respects, working with a web component is similar to interacting with ordinary JavaScript classes. For the component, you can define getters and setters, methods for data handling, and other state management mechanisms. As mentioned earlier, it is useful for a web component to have an object for its own data. If you follow this principle, the following approach can be implemented:

class MyData {
    constructor(){
        this._value = null;
    }
    setValue(value){
        this._value = value;
    }
    getValue(){
        return this._value;
    }
}
const MyDataCapable = Sup => class extends Sup {
    onConstructed(){
        this._data_object = new MyData();
    }
    setInitialValue(value) {
        this._data_object.setValue(value);
    }
    getDataValue(){
        return this._data_object.getValue();
    }
}
class MyComponent extends MyDataCapable(HTMLElement) {
    constructor() {
        super();
        this.onConstructed();
    }
    connectedCallback() {
        this.innerHTML = this.getDataValue();
    }
}

customElements.define("my-component", MyComponent);

const my_component = document.createElement("my-component");
my_component.setInitialValue("Some value");
document.body.appendChild(my_component);
Enter fullscreen mode Exit fullscreen mode

Extracting methods that pass data to the data object can be implemented as a behavior, which is just as convenient as using interfaces.

Declarative Initialization

Declarative initialization of a web component is a method of creating and configuring a component using familiar HTML markup. In this approach, data is passed through attributes, nested elements, or other mechanisms that allow for expressive and structured descriptions of the component's state and content directly in the markup.

<my-component title="Hello" data-value="42"></my-component>
Enter fullscreen mode Exit fullscreen mode

In this case, we set the component's title using the title attribute and pass a numerical value into the component via data-value. However, declarative initialization is not limited to just attributes. Data can also be passed using nested elements, which makes the structure more flexible and readable:

<my-component>
  <data-item key="name">John</data-item>
  <data-item key="age">30</data-item>
</my-component>
Enter fullscreen mode Exit fullscreen mode

This approach is particularly useful when working with complex data structures, as it clearly defines the nesting of elements and their relationships.

One of the key advantages of declarative initialization is the improvement of code readability and clarity. The markup reflects the final interface structure, making it easier to understand for both developers and designers or content editors. Unlike imperative programming, where the order of commands determines the result only at runtime, the declarative approach makes the interface structure apparent from the very beginning. This reduces the likelihood of errors, speeds up development, and simplifies project maintenance, as even without deep code knowledge, one can quickly understand which elements are present and how they are organized.

Disadvantages of Declarative Initialization

While the declarative approach is widely recognized and intuitively understandable, it is not without its drawbacks, one of which becomes evident when passing data into a component. The issue is that, in declarative initialization, the component is declared in the HTML markup through its tag, and HTML, being a text-based format, operates with string values. This means that any data passed into a component via attributes must be converted to a string type, even if the data originally consists of numbers, boolean values, arrays, objects, or functions.

This limitation significantly impacts the flexibility of working with web components, and many developers seek ways to bypass it. However, there is no universal solution. Whatever workaround you choose, it will require the programmer using your component to take extra steps and lead to the need to write auxiliary code.

Moreover, the workaround itself may not be obvious or intuitive. It imposes on the programmer the necessity to follow a certain sequence of steps, which is not always standard or intuitive. As a result, in order to use the component, one must study the component's source code to understand how it processes the passed data and manually add code each time to convert the data into the required format.

To simplify this process and make it as convenient as possible, it is best to provide support for the necessary conversions within the component itself. This can be implemented either as built-in behavior or as part of the API. In this case, the developer won't need to worry about how to pass complex data — the component will take care of properly handling the input values.

However, before deciding to tackle this issue, it is important to understand whether it is worth solving at all. Let's first explore why the declarative approach is needed and what advantages it offers.

Why Declarative Initialization is Used

There are numerous advantages to working with dynamic initialization. Dynamic initialization of a web component provides great flexibility. We don't just tell the component to accept data, but instead, we engage in a dialogue with it, during which we can ask the component to validate data before inserting it into the DOM and synchronize data fragments with each other.

However, perhaps the main advantage of dynamic initialization is that it removes the limitation related to passing data through attributes. Since JavaScript can work with any data types, a component can be passed not only strings but also objects, arrays, and functions, making interaction with it much more powerful and convenient.

However, the dynamic approach also has a downside: it requires explicit programming, meaning that every time, code must be written to call and configure the component. This increases the amount of code, complicates readability, and makes working with the component less intuitive.

This is the main reason why declarative initialization remains a more convenient and natural way to work. Using HTML notation allows you to immediately see the final structure of the components on the page, and the process itself stays simple and clear.

The second important advantage of the declarative approach is the encapsulation of logic within the component. Since all attributes are defined in the HTML markup, the developer doesn't need to worry about the order in which the component processes them — it will handle the passed values and perform the necessary actions. This simplifies the workflow and helps avoid potential errors related to method call order or data processing.

Finally, the declarative approach promotes task delegation within a team. One developer can focus on creating and configuring components in the markup based on documentation, while another can work on the component itself, not worrying about how it will be used in the final code. This makes the development process more organized and predictable.

And only one significant limitation prevents declarative initialization from becoming a universal solution. This is the inability to pass any values other than strings in HTML attributes. This means that complex data structures, such as objects or arrays, cannot be passed directly through attributes, which forces developers to look for workarounds.

But let's dig even deeper into this issue. Let's assume that we create a convenient and simple mechanism for passing complex data structures. If we learn how to pass complex values in declarative form, will it simplify the process of initializing components? I’ve mentioned this earlier. Our main goal is to simplify the implementation of the environment needed for component initialization.

Preparing Data in JSON

The simplest and most obvious way to pass complex data, such as arrays or objects, into a web component is to convert them into a JSON string. This is a standard method of data serialization, supported by all modern browsers and widely used in web development.

const data_array = ["apple", "banana", "cherry"];
const json_data = JSON.stringify(data_array);
const html_string = `<my-component data-items="${json_data}"></my-component>`;
document.body.innerHTML = html_string;
Enter fullscreen mode Exit fullscreen mode

I see this kind of code almost all the time. However, it’s not immediately clear to everyone that when converting to JSON, we get a set of different quotation marks in the resulting string:

> JSON.stringify('"');
< '"\\""'
Enter fullscreen mode Exit fullscreen mode

So, a simple conversion to JSON could potentially cause an error or pass the wrong value into the component. Therefore, it is advisable to also use encodeURIComponent.

But this is not the most important part. What matters is that now any data structure can be serialized, encoded, and passed into the web component, and the component itself can include logic to decode and process that data. This means that the environment in which the components are placed no longer needs to worry about how to prepare the data for each specific component. Instead, we have a unified encoding method that can be applied to all components in the project, and the developer only needs to remember one rule: always pass data through a special method.

function encodeJsonArray(data) {
  return encodeURIComponent(JSON.stringify(data));
}
const data_array = ["apple", "banana", "cherry"];
const json_data = encodeJsonArray(data_array);
const html_string = `<my-component data-items="${json_data}"></my-component>`;
document.body.innerHTML = html_string;
Enter fullscreen mode Exit fullscreen mode

Clearly, this significantly simplifies the process of preparing the environment for working with components. However, this approach has its drawbacks.

Imagine that we don't have access to JavaScript in the environment where we are forming the HTML markup. In that case, we won't be able to call our "magic" function, and we'll have to manually encode the data, knowing which characters need to be escaped. Can we quickly encode a string without errors? Experienced developers may be able to do this, but for most people, this process will be challenging.

An even bigger issue arises in the opposite situation: if we need to quickly understand what data has been passed in an encoded form, it's nearly impossible to do this without decoding it. encodeURIComponent turns regular characters into %XX sequences, making the string hard to read. It's quite difficult to immediately determine what exactly is hidden behind the resulting "gibberish." This reduces code readability, and as we’ve noted before, readability is one of the key reasons we use the declarative approach.

Thus, we find ourselves facing a dilemma: on one hand, we have a universal mechanism for passing data, but on the other hand, we’ve reduced the transparency of the code. The question is whether we can find a compromise solution that retains the convenience of the declarative approach while still allowing us to pass complex data structures. Are there ways to make this process more convenient and intuitive?

Using Multiple Attributes

Passing data to a web component using multiple individual attributes in a declarative initialization has several significant advantages. First and foremost, this approach makes the HTML markup more understandable and self-documenting. Each attribute clearly indicates its role, making it immediately obvious what parameters are being passed to the component and what they are responsible for.

Additionally, separating values via attributes can have a positive impact on performance. The browser does not need to parse complex JSON objects, which speeds up page processing. An added benefit is that individual attributes can be cached by the browser, while passing data in a single large JSON object may result in the entire object being recreated when even one parameter is changed, which is less efficient.

<my-component 
  user-name="John Doe" 
  user-age="30" 
  user-location="New York" 
  user-role="Admin" 
  user-status="Active" 
  user-signup-date="2025-01-01">
</my-component>
Enter fullscreen mode Exit fullscreen mode

Here, each attribute clearly defines its purpose, and even without documentation, it is clear what parameters are being passed to the component.

However, this method also has its downsides. The first drawback is the need to remember the names of all available attributes and their allowed values. With JSON or objects in JavaScript, you can work with logically grouped data, while with attributes, the developer is forced to define each parameter individually. This increases the likelihood of errors and typos, especially if there are many attributes and their list may change over time.

The second disadvantage is that some parameters may have dependencies. For example, if a component supports both dark and light modes, as well as color customization, situations may arise where one attribute affects another:

<my-component mode="dark" theme-color="blue"></my-component>
Enter fullscreen mode Exit fullscreen mode

Here, how the component interprets the passed data is important: will it automatically adjust theme-color based on the mode, or does the developer need to account for incompatible combinations? As the number of attributes increases, managing such dependencies becomes more complex, and the likelihood of errors increases.

Another issue is that attributes do not support nested structures. If you need to pass complex data, such as an array or an object with nested properties, workarounds must be found. One option is to use additional prefixes to distinguish related data:

<my-component 
  item-1-title="Item One" 
  item-1-price="10.99" 
  item-2-title="Item Two" 
  item-2-price="15.49">
</my-component>
Enter fullscreen mode Exit fullscreen mode

Thus, using multiple attributes in declarative initialization is great for simple cases where the number of parameters is small and they do not depend on each other. However, as the data structure becomes more complex, this approach starts to suffer from redundancy, reduced readability, and more complicated code maintenance. In such cases, it may be worth considering alternative solutions that maintain the convenience of the declarative approach but allow for handling complex data structures.

Using Nested Tags

To simplify the representation of complex data structures in a readable and intuitive format, nested tags can be used. This approach allows related elements to be logically grouped within a parent component, making the code easier to comprehend. The nesting in HTML markup naturally reflects the hierarchy of the data, making the structure visual and predictable. This is especially useful when passing lists, tree structures, or complex configurations, as each level of nesting clearly indicates the relationships between elements. As a result, the likelihood of errors is reduced, editing code is simplified, and the process of reading the markup becomes more natural and understandable, even for those unfamiliar with the internal workings of the component.

<my-component>
  <data-item key="user">
    <data-item key="name">John Doe</data-item> 
    <data-item key="age">30</data-item> 
    <data-item key="location">New York</data-item> 
    <data-item key="role">Admin</data-item> 
  </data-item> 
  <data-item key="status">Active</data-item> 
  <data-item key="signup"> 
    <data-item key="date">2025-01-01</data-item> 
    <data-item key="method">Email</data-item> 
  </data-item> 
</my-component>
Enter fullscreen mode Exit fullscreen mode

Now, the drawbacks. The developer needs to remember the rules for the structure of the markup: which elements can be nested inside the component, in what order they should appear, and which of them are mandatory. Unlike passing data via attributes, where the list of parameters is usually fixed and described in the API, here the developer requires additional understanding of the allowed nesting hierarchy. If the component documentation is not detailed enough, the developer will need to figure this out manually, which could take a lot of time.

Another disadvantage is that working with nested elements requires additional parsing on the component’s side. The component needs to process its child elements, analyze their contents, and build its internal logic based on them. This increases the overhead compared to simpler methods of passing data, such as passing a JSON string through an attribute. This becomes particularly noticeable when dealing with large volumes of data, as the number of nested nodes significantly increases.

Finally, nested tags can make code maintenance more difficult in large projects. If the markup structure is complex and multi-layered, making changes to one component may require adjusting other dependent components. For example, if the format of the data expected by the component changes, the entire data-passing structure must be reconsidered, which requires more effort.

Thus, using nested tags to pass data to a component has both pros and cons. On the one hand, this method makes the code visual, intuitive, and suitable for working with tree-like structures. On the other hand, it requires additional control over the data structure, increases computational load, and complicates long-term maintenance. Therefore, when choosing this approach, one should consider the complexity of the data, the frequency of changes, and the requirements for ease of working with the markup.

Comparison of Declarative Approaches

The methods of passing data to web components described above undoubtedly open up the possibility of working with complex data structures, which helps eliminate the main limitation of the declarative approach — the restriction on passing non-string values. Now we can pass arrays, objects, nested structures, and thus, the flexibility and expressiveness of declarative initialization significantly increase. However, as is often the case, solving one problem inevitably leads to the emergence of another.

The new drawback is that, by using all these methods of data transfer, we are not simplifying the process of setting up the environment but instead creating additional complexities. Now, the programmer must not only pass data in its natural form but also transform it beforehand, following certain rules that depend on the method of transmission. As a result, preparing the environment no longer appears intuitive and obvious. On the contrary, it becomes a process that requires knowledge of numerous nuances, careful adherence to specific conventions, and constant vigilance to ensure nothing is forgotten or mixed up.

A dilemma arises. On the one hand, we want to work with complex data structures, as this makes the components more powerful and easier to use. On the other hand, we would like the data transfer process itself to be as simple as possible and not require the developer to remember a myriad of specific rules and transformations. After all, the more such rules there are, the higher the chance of making an error, the more difficult the code becomes to maintain, and the longer it takes to write.

This problem can be formulated in an even broader context. The main difficulty we face is that the process of generating a component tag requires us to remember too many details.

Nevertheless, I would like to propose two ways of solving this problem, which may prove useful in two specific scenarios. They are not universal, but perhaps they will provide a direction for further thought and help minimize the very complexities we are currently discussing.

Auto-Generation of Tags

Let’s imagine a situation where we need to generate an HTML tag for a web component and then insert it into the desired place in the document. In this process, we encounter several potential issues. First, there is doubt about whether we have specified the attribute values correctly, which are necessary for the proper initialization of the component. We are not sure if the structure of the nested tags meets all the requirements and properly reflects the component's logic. Additionally, there is a chance that we may have incorrectly applied methods to transform the data, which could lead to errors in the component’s functionality. In such a situation, we don’t want to constantly remember all these complex rules, and we don’t want to waste time performing endless checks to make sure everything is done correctly. However, as often happens when a programmer faces a task they don’t want to solve manually, they turn to programming to automate the process.

Why not create a method that would allow us to automatically generate the HTML tag for the component while also verifying all the aspects we might have doubts about? This would relieve us from the need to manually track every detail and significantly simplify the process of working with the component.

class MyComponent extends HTMLElement {
    static getTag({value}){
        if(value < 0){
            throw new Error('Cannot be below zero');
        }
        return '<my-component value="' + value + '"></my-component>';
    };
    connectedCallback() {
        this.innerHTML = this.getAttribute('value');
    }
}
customElements.define("my-component", MyComponent);
Enter fullscreen mode Exit fullscreen mode

In this example, the MyComponent class has a static method getTag, which creates the component's tag and ensures that the data passed to the component follows certain rules.

Now, when we generate the necessary HTML code, it can easily be inserted into the document while maintaining the readability of the HTML markup:

document.getElementById('holder').innerHTML = '<div>' +
    '<div>' +
        '<h1>Some Title</h1>' +
    '</div>' +
    '<div>' +
        'The value is ' +
        MyComponent.getTag({
            value: 10
        }) +
    '</div>' +
'</div>';
Enter fullscreen mode Exit fullscreen mode

This approach solves our task and significantly simplifies the code. We no longer have to manually build a string with the component's tag, constantly checking the correctness of values and structure. Instead, we can use the getTag method, which performs all the necessary checks and returns us the ready tag string. This method of organizing the code not only speeds up development but also reduces the likelihood of errors related to incorrect data formation.

However, it is worth noting that this approach has its limitations. For example, it does not allow us to initialize the web component directly from pure HTML. Nevertheless, despite this limitation, this method can be extremely useful in cases where we pass data through JavaScript.

Using Data Sources

Now let’s imagine a situation where we are receiving data from some external source, and we don’t want to delve into its content or structure. We are confident in advance that the data coming from this source is in the appropriate format and is suitable for use in our component. In this case, the environment we build acts as a kind of breadboard, where the data source and the component that needs to receive and process that data are connected. It’s important to note that the environment does not need to understand the data being transferred, as it simply provides the mechanism for transferring the data, leaving the responsibility for data format to the components themselves.

To better understand this idea, imagine we are connecting an external drive to a computer via a USB port. In this case, all we need to do is connect the cable, and the responsibility for correct data exchange between the devices lies with the drivers, which ensure that the device operates according to the appropriate protocol. We only need to make the connection without worrying about how the data is being transferred between the devices.

Now, returning to web components, we can notice that if the environment doesn’t try to directly pass data to the component, but instead just connects the data source to the appropriate receiver, the entire system can become much simpler. Instead of passing large amounts of data, we can simply specify the source’s name, and the components will be able to obtain data from that source.

<my-source id=”source_id”></my-source>
<my-component id=”component_id” source=”source_id”></my-component>
Enter fullscreen mode Exit fullscreen mode

In this case, the component is not given the data itself, but merely a reference to the data source — its identifier. This identifier is a string, so this approach fully adheres to the constraints imposed by declarative HTML markup.

The component needs to receive exactly as many identifiers as it will use data sources. By passing identifiers, we can use either attributes or properties. Thus, their number is minimized. If the same name is used for the attribute throughout the project, it can become a commonly understood and widely recognized name.

The only thing to add here is that when developing the component, you should implement different behaviors depending on the data source, meaning you should develop a set of adapters that allow the component to connect to different data sources. Typically, such adapters are necessary not so much for working with sources, but for working with data formats. But we will discuss that later.

Conclusion

If our goal is to create a universal web component, it should support both declarative approaches and dynamic data initialization. However, we must not forget that the declarative approach is primarily chosen to improve code readability, and in the pursuit of simplifying code reading, we might accidentally complicate the environment setup process, making it less flexible. It’s important to find the right balance between the flexibility of the component and the complexity of its declarative description.

In my opinion, the best approach is not to pass data directly, but to organize interaction between components and data sources. This approach allows us to significantly simplify the architecture and make the code more modular and easily extensible. We will explore this approach in more detail in the next article.

To improve development practices, I have taken the liberty of standardizing a few solutions in the KoiCom library.

KoiCom documentation
KoiCom github

I hope it will be useful and provide you not only with ready-made solutions but also new ideas for development.

Top comments (0)