Part 2/2: Advanced Web Component Patterns

Part 2/2: Advanced Web Component Patterns

Integrating Advanced Web Component Patterns and React with good practice.

Table of contents

This blog is the second part of a series on Web Components. If you haven’t read Part 1, it’s highly recommended to do so before proceeding. Part 1 introduces the foundational concepts of Web Components, including Custom Elements, Shadow DOM, HTML Templates, and integration with React. Understanding these basics will help you better grasp the more advanced patterns discussed here.


Disclaimer!

This 2-part series is not meant for a quick read—it requires you to take the time to analyze the code blocks and connect the functionality as you read through. Since Web Components is a distributed concept, it assumes some prior understanding of important concepts like Events, Markup, bundlers, and HTML element attributes. By diving into the details, this series aims to set a solid foundation for you to start thinking about solutions with Web Components, enabling you to confidently use them in your own projects.

Advanced Patterns for Using Web Components in Large React Applications

In large-scale React applications, integrating Web Components requires thoughtful consideration of communication patterns, data binding, performance optimization, and testing. In this section, we will explore advanced patterns that enable Web Components and React to work together seamlessly in more complex and scalable applications.


Communication Patterns Between React and Web Components

When integrating Web Components with React, handling the flow of data between React state and Web Components is key. Two common patterns for communication include:

  1. One-Way Data Flow from React to Web Components.

  2. Two-Way Data Binding between React and Web Components using custom events.


One-Way Data Flow (React to Web Component)

In React, data typically flows one way, from parent components to child components via props. When passing data from React to a Web Component, we can achieve this one-way data flow by setting DOM attributes or properties on the Web Component.

How to Implement One-Way Data Flow

Here’s an example where React passes state to a Web Component by setting attributes on the component using refs:

import React, { useRef, useEffect, useState } from 'react';
import './MyCustomButton.js';  // Import the Web Component

function App() {
  const [buttonLabel, setButtonLabel] = useState('Initial Label');
  const buttonRef = useRef(null);

  useEffect(() => {
    if (buttonRef.current) {
      // Set the 'label' attribute on the Web Component
      buttonRef.current.setAttribute('label', buttonLabel);
    }
  }, [buttonLabel]);  // Run effect whenever 'buttonLabel' changes

  return (
    <div>
      <h1>One-Way Data Flow: React to Web Component</h1>
      <input
        type="text"
        value={buttonLabel}
        onChange={(e) => setButtonLabel(e.target.value)}
        placeholder="Enter button label"
      />
      <my-custom-button ref={buttonRef}></my-custom-button>  {/* Web Component */}
    </div>
  );
}

export default App;

Explanation:

  • React’s state (buttonLabel) is updated when the user types into the input field.

  • Using React refs, the Web Component’s label attribute is updated to reflect the current state.

This is a typical one-way data flow where React controls the data, passing it to the Web Component.


Two-Way Data Binding with Custom Events

Two-way data binding requires more interaction: React passes data to the Web Component, and the Web Component notifies React when its internal state changes. This is usually done through custom events emitted by the Web Component, which React listens to and uses to update its own state.

How to Implement Two-Way Data Binding

In this pattern, the Web Component emits custom events whenever its internal state changes. React listens to those events and updates its state accordingly. Here’s an example of a more advanced Web Component (MyCustomForm) that contains a form with name and email fields, and sends updates to React through custom events.


Example: Web Components and React State Interaction

Let’s build a custom form Web Component that interacts with React by sending input changes back through custom events. React will also be able to update the form fields directly.

Step 1: Create the MyCustomForm Web Component

class MyCustomForm extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        form {
          display: flex;
          flex-direction: column;
          width: 200px;
        }
        input {
          margin-bottom: 10px;
          padding: 8px;
        }
      </style>
      <form id="customForm">
        <label>
          Name:
          <input type="text" id="nameInput" placeholder="Enter your name" />
        </label>
        <label>
          Email:
          <input type="email" id="emailInput" placeholder="Enter your email" />
        </label>
      </form>
    `;

    this.nameInput = this.shadowRoot.querySelector('#nameInput');
    this.emailInput = this.shadowRoot.querySelector('#emailInput');

    // Emit custom events when form fields are updated
    this.nameInput.addEventListener('input', () => this._emitInputEvent());
    this.emailInput.addEventListener('input', () => this._emitInputEvent());
  }

  // Emit the custom event with form data
  _emitInputEvent() {
    this.dispatchEvent(new CustomEvent('formInputChanged', {
      detail: {
        name: this.nameInput.value,
        email: this.emailInput.value,
      },
      bubbles: true,
      composed: true,
    }));
  }

  // Method to set form data from React
  setFormData(name, email) {
    this.nameInput.value = name;
    this.emailInput.value = email;
  }
}

customElements.define('my-custom-form', MyCustomForm);

Explanation:

  • The Web Component contains two inputs, name and email.

  • Whenever the user types in either field, the component emits a formInputChanged custom event containing the updated values.

  • The Web Component also provides a method setFormData that allows React to update the form fields.

Step 2: Integrate the Web Component with React for Two-Way Data Binding

Now, we will connect the Web Component to React, allowing React to:

  • Listen for input changes from the Web Component.

  • Update the Web Component’s form fields when React’s state changes.

import React, { useState, useEffect, useRef } from 'react';
import './MyCustomForm.js';  // Import the Web Component

function App() {
  const [formData, setFormData] = useState({ name: '', email: '' });
  const formRef = useRef(null);

  useEffect(() => {
    if (formRef.current) {
      // Listen for 'formInputChanged' event from the Web Component
      const handleInputChange = (event) => {
        setFormData({
          name: event.detail.name,
          email: event.detail.email,
        });
      };

      formRef.current.addEventListener('formInputChanged', handleInputChange);

      // Clean up event listener on component unmount
      return () => {
        formRef.current.removeEventListener('formInputChanged', handleInputChange);
      };
    }
  }, []);

  // Update Web Component form fields whenever React state changes
  useEffect(() => {
    if (formRef.current) {
      formRef.current.setFormData(formData.name, formData.email);
    }
  }, [formData]);

  return (
    <div>
      <h1>Two-Way Data Binding with Web Components and React</h1>
      <label>
        Name:
        <input
          type="text"
          value={formData.name}
          onChange={(e) => setFormData({ ...formData, name: e.target.value })}
          placeholder="React-controlled name"
        />
      </label>
      <label>
        Email:
        <input
          type="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          placeholder="React-controlled email"
        />
      </label>

      <my-custom-form ref={formRef}></my-custom-form>  {/* Web Component */}

      <h2>React State</h2>
      <p>Name: {formData.name}</p>
      <p>Email: {formData.email}</p>
    </div>
  );
}

export default App;

How It Works:

  • Web Component to React: The Web Component emits a formInputChanged custom event when its fields change. React listens for this event and updates its internal state (formData).

  • React to Web Component: Whenever React’s state changes (e.g., through the inputs controlled by React), it calls the Web Component’s setFormData method to update the Web Component’s form fields.

This example demonstrates two-way data binding between React and Web Components, providing a fully interactive form.


Optimizing Performance in Large Applications

In large React applications, performance optimization is crucial. Web Components offer great encapsulation and reusability, but they must be used wisely to avoid slowing down the app. Two important techniques are lazy loading and memoization.

1. Lazy Loading Web Components in React

Lazy loading defers the loading of Web Components until they are actually needed. This reduces the initial bundle size and improves performance.

import React, { Suspense, lazy } from 'react';

// Lazy-load the Web Component
const LazyCustomForm = lazy(() => import('./MyCustomForm.js'));

function App() {
  return (
    <div>
      <h1>Lazy Loading Web Components in React</h1>
      <Suspense fallback={<div>Loading form...</div>}>
        <LazyCustomForm />  {/* Web Component loaded when necessary */}
      </Suspense>
    </div>
  );
}

export default App;

Explanation:

  • React.lazy dynamically imports the Web Component.

  • Suspense provides a fallback UI (like a loading spinner) while the component is being loaded.

2. Memoization to Prevent Unnecessary Re-renders

To prevent unnecessary re-renders of Web Components in React, use memoization. This ensures that Web Components only re-render when their dependencies change.

import React, { useMemo } from 'react';

function App() {
  // Memoize the Web Component to avoid unnecessary re-renders
  const memoizedForm = useMemo(() => <my-custom-form></my-custom-form>, []);

  return (
    <div>
      <h1>Memoized Web Component in React</h1>
      {memoizedForm}  {/* Web Component won't re-render unless dependencies change */}
    </div>
  );
}

export default App;

By wrapping the Web Component in useMemo, React only re-renders the Web Component when its dependencies change, optimizing performance.


Testing and Debugging Web Components in React

Testing Web Components in React ensures that they function correctly within the application. Tools like Jest and Cypress can be used to test both unit and end-to-end functionality.

Testing Web Components with Jest

Here’s a simple Jest test to check whether the Web Component renders correctly in React:

import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import './MyCustomForm.js';  // Import the Web Component

test('renders the custom form Web Component', () => {
  const { getByPlaceholderText } = render(<my-custom-form></my-custom-form>);

  // Check if the inputs are rendered
  expect(getByPlaceholderText('Enter your name')).toBeInTheDocument();
  expect(getByPlaceholderText('Enter your email')).toBeInTheDocument();
});

This test ensures that the Web Component renders as expected within the React environment.

Event Bus Pattern for Web Components and React

In more complex applications, managing communication between React and Web Components can become tricky—especially when there are multiple components needing to communicate in a decoupled manner. One solution to this challenge is using the Event Bus Pattern. The Event Bus acts as a central hub for sending and receiving messages or events between components without them being tightly coupled to each other.


Introduction to the Event Bus Pattern

The Event Bus Pattern allows multiple components to communicate with each other indirectly, by emitting and listening for events on a centralized "event bus." This pattern is especially useful in scenarios where:

  • Multiple Web Components or React components need to communicate.

  • Components need to stay decoupled (i.e., they shouldn’t directly reference each other).

  • We need a global event-driven communication mechanism.

The Event Bus allows Web Components to emit events, and React (or other components) can listen to these events—or vice versa. This leads to cleaner, more maintainable code, especially in larger applications.


How JavaScript Module Caching Simulates Singleton Behavior

In JavaScript, each module is only loaded and executed once per application. After the initial load, the module is cached by the JavaScript engine, meaning subsequent imports of the same module return the same instance. This behavior enables us to create singleton-like objects without explicitly writing singleton code.

By leveraging JavaScript's module system, we can create an Event Bus that acts as a singleton—i.e., there will only ever be one instance of the Event Bus shared across all components.


Creating a Singleton Event Bus for Web Components and React

To create an Event Bus, we can use the EventEmitter class from Node.js or write a simplified custom Event Bus using the browser’s built-in CustomEvent system.

Here’s how to create a Singleton Event Bus using JavaScript module caching:

Step 1: Create the eventBus.js Module

// eventBus.js
class EventBus {
  constructor() {
    this.listeners = {};
  }

  // Register an event listener for a specific event
  on(event, callback) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  // Emit an event, calling all registered listeners
  emit(event, data) {
    if (this.listeners[event]) {
      this.listeners[event].forEach(callback => callback(data));
    }
  }

  // Remove an event listener
  off(event, callback) {
    if (this.listeners[event]) {
      this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
    }
  }
}

// Create a singleton instance
const eventBus = new EventBus();
export default eventBus;

In this example:

  • We define an EventBus class that stores listeners in a simple listeners object.

  • Components can subscribe to events via on, emit events via emit, and remove listeners with off.

  • The singleton instance of the EventBus is exported, and because of JavaScript’s module caching, all components that import eventBus.js will share this same instance.


Explanation of Singleton Event Bus

By using a singleton pattern, you ensure that the Event Bus exists as a single shared instance across your application. Both React components and Web Components can subscribe to events and communicate with each other via this bus, without directly referencing each other.

This decoupled communication system leads to a cleaner architecture:

  • Web Components can focus on their individual responsibilities without worrying about who listens to their events.

  • React components can manage their state and behavior independently, only reacting when events are emitted.

The Event Bus effectively decouples your communication logic and keeps your Web Components and React components loosely connected through events.


Example: Web Component and React Communication via Event Bus

Now let’s see how the Event Bus can be used for communication between a Web Component and a React component. In this example:

  1. A Web Component will emit events via the Event Bus.

  2. React will listen for these events and update its state accordingly.

Step 1: Web Component that Emits Events via the Event Bus

import eventBus from './eventBus.js';

class MyCustomButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <button>Click Me!</button>
    `;

    // Emit an event via the Event Bus when the button is clicked
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      eventBus.emit('buttonClicked', { message: 'Button was clicked!' });
    });
  }
}

customElements.define('my-custom-button', MyCustomButton);

In this Web Component:

  • When the button is clicked, an event (buttonClicked) is emitted through the Event Bus.

  • The event includes some detail ({ message: 'Button was clicked!' }) that React can use to update its state.

Step 2: React Component Listening to the Event Bus

import React, { useEffect, useState } from 'react';
import eventBus from './eventBus.js';
import './MyCustomButton.js';  // Import the Web Component

function App() {
  const [message, setMessage] = useState('');

  useEffect(() => {
    // Listen for the 'buttonClicked' event from the Event Bus
    const handleButtonClicked = (data) => {
      setMessage(data.message);
    };

    eventBus.on('buttonClicked', handleButtonClicked);

    // Clean up the event listener when the component unmounts
    return () => {
      eventBus.off('buttonClicked', handleButtonClicked);
    };
  }, []);

  return (
    <div>
      <h1>Event Bus Communication Between Web Components and React</h1>
      <my-custom-button></my-custom-button>  {/* Web Component */}
      <p>{message}</p>  {/* Display message from Event Bus */}
    </div>
  );
}

export default App;

In this React component:

  • We use the useEffect hook to subscribe to the buttonClicked event from the Event Bus.

  • When the Web Component emits this event, React listens for it and updates its message state.

  • The event listener is cleaned up when the React component unmounts using off.


Real-World Use Case: Decoupling Web Components and React with Event Bus

In a real-world scenario, the Event Bus becomes invaluable when you have multiple Web Components and React components that need to communicate but must remain decoupled. For example, consider a dashboard application where various Web Components (charts, buttons, tables) are used alongside React components (filters, form inputs) to interact with the user.

Example Use Case:

  • Web Component (Chart): Displays a chart and listens for data updates via the Event Bus. The chart should not know who or what is updating the data.

  • React Component (Filter): Provides user inputs (filters) and emits events through the Event Bus to update the chart data.

  • Event Bus: Acts as the mediator, ensuring the filter and chart components can communicate without directly referencing each other.

Step 1: Web Component (Chart) Listening to Event Bus

import eventBus from './eventBus.js';

class MyChart extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<div id="chart">No data available</div>`;

    // Listen for data updates via the Event Bus
    eventBus.on('updateChartData', (data) => {
      this.updateChart(data);
    });
  }

  updateChart(data) {
    this.shadowRoot.querySelector('#chart').textContent = `Chart Data: ${data}`;
  }
}

customElements.define('my-chart', MyChart);

Step 2: React Component (Filter) Emitting Events via Event Bus

import React, { useState } from 'react';
import eventBus from './eventBus.js';
import './MyChart.js';  // Import the Web Component

function FilterComponent() {
  const [filterValue, setFilterValue] = useState('');

  const handleFilterChange = (e) => {
    const value = e.target.value;
    setFilterValue(value);
    // Emit the 'updateChartData' event via the Event Bus
    eventBus.emit('updateChartData', value);
  };

  return (
    <div>
      <h1>Event Bus Example: Filtering Data for Chart</h1>
      <input
        type="text"
        value={filterValue}
        onChange={handleFilterChange}
        placeholder="Enter filter value"
      />
      <my-chart></my-chart>  {/* Web Component */}
    </div>
  );
}

export default FilterComponent;

How This Works:

  • The FilterComponent (React) emits a updateChartData event via the Event Bus whenever the input value changes.

  • The MyChart Web Component listens for the updateChartData event and updates its chart with the new data.

  • The two components remain decoupled—the Web Component knows nothing about the React component and vice versa. They only communicate through the Event Bus.

Contextual Web Components: Managing Shared State Across Web Components

In large-scale applications, it’s common for multiple components to need access to shared data—whether it’s user information, settings, or global configuration values. While React’s Context API allows React components to share data efficiently, integrating Web Components in such systems requires a different approach.

The Contextual Web Components pattern is an advanced way to allow Web Components to access shared state across the application without relying solely on the parent-to-child prop-passing model.


Why Use Contextual Web Components?

In a large-scale app, you might have multiple independent Web Components (e.g., forms, modals, charts) that need access to global data or shared state. Passing data manually between components using DOM attributes or events can get cumbersome, especially if the data is needed by deeply nested or widely dispersed components.

The Contextual Web Components pattern allows you to:

  • Share data globally across Web Components without manually passing it as attributes.

  • Maintain a centralized store of data accessible to all components.

  • Dynamically update the shared state, and have the Web Components automatically react to changes.


Implementing Contextual Web Components with a Central Data Store

We can achieve context sharing by creating a centralized data store (like a global context provider) that Web Components can subscribe to. This mimics how React's Context API provides a way for components to access global data.

Step 1: Create a Central Store for Shared Data

We’ll create a simple Store that manages the shared state and provides methods for components to subscribe and listen to updates.

// store.js
class Store {
  constructor() {
    this.state = {
      theme: 'light',  // Example shared data: global theme
      user: { name: 'Guest' }
    };
    this.listeners = [];
  }

  // Method to update the store's state
  updateState(newState) {
    this.state = { ...this.state, ...newState };
    this.listeners.forEach((listener) => listener(this.state));
  }

  // Method to subscribe components to state changes
  subscribe(callback) {
    this.listeners.push(callback);
    callback(this.state);  // Immediately call the callback with the current state
  }

  // Method to unsubscribe components from state updates
  unsubscribe(callback) {
    this.listeners = this.listeners.filter((listener) => listener !== callback);
  }
}

// Singleton instance of the store
const store = new Store();
export default store;

In this Store class:

  • state holds the shared data that all Web Components can access (e.g., theme, user information).

  • subscribe allows Web Components to listen for changes to the state.

  • updateState is used to update the global state, triggering a re-render or state update in all subscribed components.


Step 2: Web Component that Subscribes to the Store

Now, let’s create a Web Component that subscribes to the store and automatically reacts to state changes (for example, a theme-aware Web Component).

import store from './store.js';

class ThemedButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 10px 20px;
          border: none;
          border-radius: 5px;
          cursor: pointer;
        }
      </style>
      <button id="themedButton">Click Me!</button>
    `;

    // Subscribe to the store to listen for theme changes
    store.subscribe((state) => {
      this.updateTheme(state.theme);
    });
  }

  // Update the button's theme based on global state
  updateTheme(theme) {
    const button = this.shadowRoot.getElementById('themedButton');
    if (theme === 'dark') {
      button.style.backgroundColor = '#333';
      button.style.color = '#fff';
    } else {
      button.style.backgroundColor = '#fff';
      button.style.color = '#333';
    }
  }
}

customElements.define('themed-button', ThemedButton);

In this Web Component:

  • The button listens to the global theme stored in the Store. Whenever the theme changes, the component updates its style.

  • The Web Component subscribes to the store via store.subscribe(), receiving state updates and updating itself accordingly.


Step 3: React Component that Updates the Global Store

Next, we’ll create a React component that can update the shared state in the store. This way, React can interact with the same store that Web Components use, allowing seamless communication between React and Web Components.

import React, { useState } from 'react';
import store from './store.js';
import './ThemedButton.js';  // Import the Web Component

function ThemeSwitcher() {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    // Update the global store with the new theme
    store.updateState({ theme: newTheme });
  };

  return (
    <div>
      <h1>Contextual Web Components with a Global Store</h1>
      <button onClick={toggleTheme}>
        Switch to {theme === 'light' ? 'Dark' : 'Light'} Theme
      </button>
      <themed-button></themed-button>  {/* Web Component */}
    </div>
  );
}

export default ThemeSwitcher;

In this React component:

  • The toggleTheme function updates the global store by calling store.updateState() with the new theme.

  • The ThemedButton Web Component, which is subscribed to the store, will automatically update its style when the theme changes.


Step 4: Unsubscribing from the Store

To avoid memory leaks, it’s essential to unsubscribe components from the store when they are no longer needed (e.g., when a Web Component is removed from the DOM).

Let’s modify the ThemedButton component to handle unsubscription when it’s disconnected from the DOM:

class ThemedButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 10px 20px;
          border: none;
          border-radius: 5px;
          cursor: pointer;
        }
      </style>
      <button id="themedButton">Click Me!</button>
    `;

    this.handleStateChange = this.handleStateChange.bind(this);
    store.subscribe(this.handleStateChange);
  }

  // Update the button's theme
  handleStateChange(state) {
    this.updateTheme(state.theme);
  }

  updateTheme(theme) {
    const button = this.shadowRoot.getElementById('themedButton');
    if (theme === 'dark') {
      button.style.backgroundColor = '#333';
      button.style.color = '#fff';
    } else {
      button.style.backgroundColor = '#fff';
      button.style.color = '#333';
    }
  }

  // Unsubscribe when the component is disconnected
  disconnectedCallback() {
    store.unsubscribe(this.handleStateChange);
  }
}

customElements.define('themed-button', ThemedButton);

Explanation:

  • The disconnectedCallback lifecycle method is used to unsubscribe the component from the store when it’s removed from the DOM.

  • This ensures the component no longer listens for state updates when it’s not visible or in use.


Benefits of Contextual Web Components

  1. Decoupling: Similar to React’s Context API, Web Components can independently subscribe to a global store, without needing direct communication between them.

  2. Centralized State Management: The state is managed in a central store, allowing multiple components (both Web Components and React components) to share and update the same data.

  3. Real-Time Updates: Web Components can automatically react to changes in the shared state, just like React components that consume context.


Slot-Based Composition Pattern for Web Components

When building large-scale applications with reusable components, you often need a way to create flexible, dynamic layouts. React and other frameworks offer this through props and children—but with Web Components, we can achieve the same flexibility using slots.

The Slot-Based Composition Pattern allows developers to compose UIs with customizable content that can be passed to a Web Component, while keeping the component's internal logic and structure isolated. This pattern is particularly powerful when you want to give users or other components control over specific parts of a Web Component’s layout without sacrificing encapsulation.


Why Use the Slot-Based Composition Pattern?

In large applications, you often need to create components like modals, cards, or navigation bars that are composed of different sections (e.g., headers, content areas, and footers) but allow the consumer of the component to customize what is displayed inside each section.

Using slots, you can:

  • Allow dynamic content insertion while maintaining encapsulation of the Web Component’s structure and styles.

  • Build reusable components that can be customized without rewriting the entire component or exposing internal styles and logic.

  • Support complex compositions where the consumer of the component controls specific content areas, such as headers, footers, or action buttons.


How Slot-Based Composition Works

In Web Components, slots allow you to define placeholders in your component’s template where external content can be inserted. Slots are analogous to props.children in React, but they provide more control over where the content is placed within the component.

Basic Slot Example

Here’s a simple example of how slots work in Web Components:

class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #ccc;
          border-radius: 5px;
          padding: 10px;
          max-width: 300px;
        }
        .card-header {
          font-weight: bold;
        }
        .card-footer {
          margin-top: 10px;
          text-align: right;
        }
      </style>
      <div class="card">
        <div class="card-header">
          <slot name="header"></slot>  <!-- Named slot for header -->
        </div>
        <div class="card-content">
          <slot></slot>  <!-- Default slot for main content -->
        </div>
        <div class="card-footer">
          <slot name="footer"></slot>  <!-- Named slot for footer -->
        </div>
      </div>
    `;
  }
}

customElements.define('my-card', MyCard);

This MyCard component defines three slots:

  • A header slot for the title or header of the card.

  • A default slot for the main content of the card.

  • A footer slot for actions or other footer elements.

When this Web Component is used, the content passed into the named slots (header and footer) and the default slot will be inserted into the appropriate places inside the card.


Using the Component with Slot-Based Composition

Here’s how you can use the MyCard Web Component in a React or vanilla JavaScript app, providing custom content for the card’s header, main body, and footer:

import React from 'react';
import './MyCard.js';  // Import the Web Component

function App() {
  return (
    <div>
      <h1>Slot-Based Composition with Web Components</h1>
      <my-card>
        {/* Slot for the card header */}
        <h2 slot="header">Card Title</h2>

        {/* Default slot for the main content */}
        <p>This is the main content of the card. You can put any HTML here.</p>

        {/* Slot for the card footer */}
        <button slot="footer">Confirm</button>
      </my-card>
    </div>
  );
}

export default App;

Explanation:

  • The header slot is filled with a <h2> element.

  • The default slot contains a <p> element with the main content of the card.

  • The footer slot is filled with a <button> element for an action.

This approach allows you to keep the card component’s layout and styles encapsulated while giving the consumer full control over what content appears in each section.


Advanced Slot-Based Composition: Dynamic Layouts

You can take the Slot-Based Composition Pattern even further by allowing Web Components to accept multiple named slots or conditionally render slots based on the structure of the content passed in.

For example, let’s create a Modal Component that allows consumers to insert customizable header, body, and footer content, but only renders the footer if specific content is provided.

Step 1: Create a Modal Web Component

class MyModal extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .modal {
          display: none;
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background-color: white;
          padding: 20px;
          border-radius: 10px;
          box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        }
        .modal.show {
          display: block;
        }
        .modal-header {
          font-size: 18px;
          font-weight: bold;
          margin-bottom: 10px;
        }
        .modal-footer {
          margin-top: 20px;
          text-align: right;
        }
      </style>
      <div class="modal" id="modal">
        <div class="modal-header">
          <slot name="header"></slot>  <!-- Named slot for header -->
        </div>
        <div class="modal-body">
          <slot></slot>  <!-- Default slot for body content -->
        </div>
        <div class="modal-footer">
          <slot name="footer"></slot>  <!-- Named slot for footer -->
        </div>
      </div>
    `;
  }

  // Method to show the modal
  open() {
    this.shadowRoot.querySelector('#modal').classList.add('show');
  }

  // Method to close the modal
  close() {
    this.shadowRoot.querySelector('#modal').classList.remove('show');
  }
}

customElements.define('my-modal', MyModal);

This MyModal Web Component:

  • Allows customizable header, body, and footer sections using named slots.

  • Provides open() and close() methods to control the visibility of the modal.

Step 2: Using the Modal in a React Application

Now, let’s use this modal Web Component in a React application and show how you can dynamically control the slots’ content.

import React, { useRef } from 'react';
import './MyModal.js';  // Import the Web Component

function App() {
  const modalRef = useRef(null);

  const openModal = () => {
    modalRef.current.open();  // Open the modal when the button is clicked
  };

  const closeModal = () => {
    modalRef.current.close();  // Close the modal
  };

  return (
    <div>
      <h1>Slot-Based Modal Example</h1>
      <button onClick={openModal}>Open Modal</button>

      <my-modal ref={modalRef}>
        {/* Slot for the modal header */}
        <h3 slot="header">Modal Title</h3>

        {/* Default slot for the modal body */}
        <p>This is the content inside the modal body.</p>

        {/* Slot for the modal footer */}
        <button slot="footer" onClick={closeModal}>Close</button>
      </my-modal>
    </div>
  );
}

export default App;

In this example:

  • slot="header" is used for the modal title.

  • slot="footer" is used for the "Close" button.

  • The default slot is filled with body content.

The Slot-Based Composition Pattern allows for maximum flexibility and composability of the Web Component, ensuring that consumers of the modal can inject any content they need while maintaining encapsulation of the modal’s layout and behavior.


Benefits of the Slot-Based Composition Pattern

  1. Customizable Components: Consumers can inject any content they want into predefined sections of the Web Component, making components highly customizable without altering the underlying logic.

  2. Encapsulation and Isolation: Web Components still maintain their internal styles and behavior while allowing dynamic content to be passed in. This ensures the layout and functionality remain consistent.

  3. Reusability: Once a component is built using slots, it can be reused across various parts of the application, with different content passed into it based on the context.

Conclusion for Part 2

In this part of the blog series, we delved into advanced patterns for using Web Components in large-scale applications. From exploring two-way data binding and event-driven communication via the Event Bus pattern, to implementing contextual components for managing shared state and using slot-based composition to create flexible, customizable UI components, we have covered a variety of ways to enhance the integration of Web Components in modern applications.

Did you find this article valuable?

Support Adeesh's Software Engineering Insights by becoming a sponsor. Any amount is appreciated!