Picture this: you've probably heard about web components multiple times — their magic, their unique ability to isolate through Shadow DOM. There are countless articles, endless webinars, and it feels as if the entire web development community is focused solely on one thing: isolating styles and markup. But what if I told you that this is merely the tip of the iceberg? That web components have far more capabilities, extending well beyond the Shadow DOM?

Today, I want to take you beyond the surface and shine a light on something that often goes unnoticed: how web components handle data. Why does this topic get so little attention? Perhaps it’s because the official specification doesn’t highlight this potential. But once you start exploring, you’ll realize just how much is being overlooked.

I apologize in advance if I start off slowly and become a bit tedious. It's necessary for the task at hand.

Why Do Web Components Need Data?

Web components are designed to be self-contained elements that can function independently from other parts of the system. This autonomy simplifies their integration into different areas of an application and allows for easy reuse across various projects. The key to this independence is encapsulation, which not only hides the component's appearance but also its internal behavior. This behavior, in turn, is tightly linked to how the component manages and uses data.

For example, consider a calculator component designed to perform basic arithmetic operations. This component manages behavior such as displaying the current result, storing the previous result, and performing calculations. To achieve this, it maintains data such as the current result, the previous result, and settings like restrictions on input values.

The data that web components use can be described as "local state." This local state stores essential information for the component's operation. It can include temporary data, intermediate calculation results, or other values needed to carry out specific tasks within the component.

To begin with, let’s look at a simple example of how component properties can store basic data:

class SimpleCalculator extends HTMLElement {

  constructor() {
    super();
    this._data = {
      result: 0,
      previous_result: 0
    };
    …
  }
  undo(){
    this._data.result = this.data.previous_result;
  }
  add(value) {
    this._data.previous_result = this.data.result;
    this._data.result += value;
  }
  …
  displayResult() {
    this.innerHTML = `<p>The result is: ${this._data.result}</p>`;
  }
  …
}

The data in this example is often referred to as a "dumb" data model. Its main purpose is simply to store information without involving any complex logic. Despite its simplicity, this is a step forward because the data needed for the component’s operation is stored internally, avoiding the use of global variables.

By keeping the data inside the component, we ensure that it’s isolated from the external system, meaning the component can function independently. Furthermore, to emphasize the encapsulation of the data, we prefix the property name with an underscore. This convention signals that the property is intended for internal use only and should not be accessed externally.

So, what else can be achieved with this "dumb" model inside a component? One useful feature is caching. By storing data within the component, we can avoid unnecessary recalculations, redundant network requests, or other resource-heavy operations. In our example, saving the previous calculation result allows the implementation of an undo function, which improves performance and user experience.

Thick Model

Is the "dumb" data model a universal solution for all cases? When working with simple components, it can indeed be sufficient. This model is easy to implement and handles basic data storage and processing tasks well. However, as the logic of the components becomes more complex, maintaining the "dumb" model becomes increasingly difficult. When a component involves multiple data operations, including modification and analysis, it makes sense to simplify the structure by separating this logic into distinct classes. One such approach is using a "thick" data model to isolate all data-related processes from the component itself.

Let’s consider an example. A "thick" model can be represented by a separate class that stores the data and provides methods for modifying it. Within this model, not only can we store the result and the previous value, but we can also add auxiliary logic, such as automatically saving the previous result before any calculations. This greatly simplifies the component, freeing it from managing the data directly.

class CalculatorModel {
  constructor() {
    this._data = {
      result: 0,
      previous_result: 0
    };
  }
  _rememberPreviousResult(){
    this._data.previous_result = this._data.result;
  }
  add(value) {
    this._rememberPreviousResult();
    this._data.result += value;
  }
  …
}

class SimpleCalculator extends HTMLElement {
  constructor() {
    super();
    this._createModel();
  }
  _createModel(){
    this._model = new CalculatorModel();
  }
  …
  add(value) {
    this._model.add(value);
  }
  …
}

By using the thick model, we not only encapsulate the data within the component, but we also hide some of the behavior from the component itself. The component is now unaware of both the data structure and the details of how data is set, modified, and retrieved. Its own behavior is simplified.

With the introduction of the thick model, the component takes on the role of a controller. It manages the model but does not need to know its inner workings. As a result, the component no longer depends on the data structure or the methods used to process it. All it needs to know is the model’s interface — the set of methods it provides. This approach allows for easy replacement of one model with another.

Moreover, the thick model becomes reusable: it can now be used not just in one component but in others as well, provided they work with similar data.

For even greater flexibility, the Adapter pattern can be used. This pattern ensures compatibility between the component and the model, even if their interfaces initially differ. For example, if a component requires a model with additional logic, we can create an adapter to add this logic while maintaining the common interface.

export const CalculatorModelCapable = Sup => class extends Sup {
  _createModel(){
    this._model = new CalculatorModel();
  }
  …
  add(value) {
    this._model.add(value);
  }
  …
}

class SimpleCalculator extends HTMLElement {
  constructor() {
    super();
    this._createModel();
  }
  …
}

Now, for another component to work with the same model, it’s enough to apply this adapter. If we need to use a different model, we can either override its creation method or connect a different adapter. This ensures that the component remains unchanged, while its behavior is controlled by the model it’s connected to.

Thus, separating the logic into a thick data model achieves several important goals. First, it makes the component lighter and easier to understand, leaving it only with management tasks. Second, the model becomes an independent and reusable element within the system. Third, using patterns like the Adapter ensures flexibility and scalability, allowing the data-handling logic to adapt to changing requirements. While this might seem overkill in simpler cases, it lays the foundation for building more complex and stable architectures in the future.

Decomposition of SSOT

Let's explore the possibility of going even further in terms of organizing components and their interaction. Previously, we discussed how the autonomy of elements simplifies their integration into different parts of an application and makes them suitable for reuse in other projects. However, the autonomy of components opens up another interesting opportunity: it allows for the decomposition of the global Single Source of Truth (SSOT) and partially shifting it into individual components. This means that instead of having one global SSOT in the system, we can work with local SSOTs that encapsulate part of the logic and data.

The idea is that splitting the global source of truth into local ones allows us to create components that are not only autonomous in their visual aspects but also have their own local logic necessary to perform their tasks. These components cease to be just visual elements and become independent mini-systems that manage their own data and behavior. This significantly increases their independence from the rest of the application, which in turn improves the stability and simplifies the evolution of the system.

Moreover, when we talk about components, we are not limited to small UI elements like buttons, tables, or charts. A component can refer to more complex and larger application elements, such as a settings panel that combines several different functions, a registration or data entry form, or even a section with multiple interactive charts. Each of these components can have its own local source of truth, which manages the state and logic only within that specific element.

The decomposition of SSOT into local parts simplifies the management of an application’s state. For example, instead of using a global source of truth for all form elements, we can encapsulate the state within the form, ensuring its independence from other parts of the application. This not only reduces the complexity of development but also makes the system more flexible, allowing components to be replaced or modified without requiring changes to the global logic.

This approach to architectural design is especially useful in large-scale applications where global logic can become overloaded, and changes to one part of it can have cascading effects across the entire system. Local sources of truth help minimize such risks by creating isolated areas of responsibility, which simplifies maintenance and improves code readability.

Conclusion

The ability of web components to store their own data allows us to see them as more than just simple visual elements of the interface. Now, they can be considered self-contained modules that integrate data, logic, and presentation. This approach makes components an effective tool for building application architecture. They can encapsulate complex behavior, manage their internal state, and organize interactions with other elements of the system at a higher level. This transforms web components into a versatile tool for creating flexible and scalable applications.

To further develop the approach described here and significantly simplify my own tasks related to interface creation, I developed the KoiCom library, which is based on data management and data transfer between components.

KoiCom documentation
KoiCom github

Ultimately, I hope such solutions will help developers adopt a more modern approach to interface design, making applications more scalable and easier to maintain.

Author Of article : Valkoivo Read full article