Skip to main content

Dim: Functional Web Components

· 21 min read
xoron
positive-intentions

Modern JavaScript frameworks like React and Vue have popularized functional and declarative approaches to web development. While these frameworks have made creating dynamic web applications more accessible, it's worth exploring the potential of web components in this evolving landscape. Lit, with its minimalistic and declarative approach, stands out as an appealing base for leveraging web components in modern frontend development.

In this guide, we'll explore the step-by-step process of creating a UI framework for web components that leverages a functional reactive programming style. We'll start by creating a set of custom hooks that mirror React's hook system. We'll then implement these hooks within a Lit-based web component library. By the end of this tutorial, you'll have hands-on experience constructing a fully functional todo list application using Lit and our custom hooks. This practical approach will demonstrate how to combine the best of modern frontend frameworks with the native capabilities of web components, offering a powerful and flexible solution for your web development projects.

What are Web Components?

Web components are a set of standardized web platform APIs that allow developers to create reusable and encapsulated HTML elements. They enable developers to define new HTML tags, encapsulate their functionality, and reuse them across different web applications.

A Custom define Function with Lit

Embarking on our exploration of web components through the Lit library, an essential step is understanding how we can bridge the gap between the traditional object-oriented class components and a more functional approach. To achieve this, let's craft a bespoke define function. This function aims to simplify the process of defining new web components, enabling developers to leverage functional components within the Lit framework, thus combining the best of both worlds.

import { LitElement } from "lit";
export function define({ tag, component: CustomFuntionalComponent }) {
class CustomComponent extends LitElement {
render() {
// get all attributes
const attributes = Array.from(this.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {});
return CustomFuntionalComponent({
...attributes,
children: this.innerHTML,
});
}
}
window.customElements.define(tag, CustomComponent);
}

The define Function Explained

The define function serves as a utility to create and register custom elements with the browser, encapsulating the complexity of class-based components in favor of a functional design pattern. Here’s a step-by-step breakdown of how the function operates:

Importing LitElement: We begin by importing LitElement from the lit package, the core class that all Lit elements extend from. This import is crucial as it provides the foundational web component functionalities that our custom components will inherit.

Function Definition: The define function is declared with a single parameter — an object that includes tag, the custom element’s name, and component, a functional component that defines the element’s behavior and rendering logic.

Creating a Custom Class: Inside the function, we define a new class CustomComponent that extends LitElement. This class will serve as the blueprint for our custom element, encapsulating the functional component’s logic within the render method of a LitElement class.

The Render Method: Within CustomComponent, the render method is where the magic happens. It begins by extracting all attributes from the element and consolidating them into an object. This process involves iterating over the element’s attributes and accumulating their names and values, making them easily accessible to our functional component.

Invoking the Functional Component: With the attributes collected, the render method then calls the CustomFuntionalComponent, passing in the attributes, the element’s inner HTML (as children), and a reference to the component instance itself. This step effectively bridges the gap between the class-based nature of LitElement and the functional approach, allowing developers to define the component’s UI and behavior in a functional manner.

Registering the Custom Element: Finally, the function concludes by registering the custom element with the browser’s Custom Elements Registry through window.customElements.define, associating the specified tag with our CustomComponent class.

Practical Usage

import { define } from "./custom-hooks";
import { MyFunctionalComponent } from "./my-functional-component";

// Define a new custom element
define({
tag: "my-custom-element",
component: MyFunctionalComponent,
});

By encapsulating the component definition logic, this utility function not only enhances the development experience but also promotes a functional programming style in the context of web components. It empowers developers to focus on the functional aspects of their components — such as props, state, and rendering logic — without being bogged down by the boilerplate code often associated with class-based components.

Creating a custom useState hook

In the pursuit of enhancing web components with reactive state management capabilities akin to those found in React, we can introduce a custom useState function. This approach allows developers to manage state within Lit-based components more intuitively, drawing inspiration from the familiar hooks pattern popularized by React. This section will delve into the mechanics of the useState function, demonstrating how it facilitates state management in a functional programming context.

Overview of the useState Function

The useState function is designed to mimic React’s useState hook, providing a way to declare state variables in functional components. This custom function underscores a shift towards a more reactive and functional approach within the context of web components, particularly those built using the Lit library. Here’s a closer look at how it functions:

State Initialization: The function accepts an initialState value, which sets the starting state, along with a component reference (typically a LitElement instance) and a unique id to ensure that each state variable is distinct.

Unique Property Naming: To avoid conflicts and ensure that each state variable is uniquely identifiable, the function constructs a property name using the provided id. This property name (propName) follows the format state-${id}, creating a dedicated namespace for each state within the component.

State Storage and Retrieval: The component’s state is stored directly on the component instance using the unique property name. If the state variable has not been initialized, it’s set to the initialState. This design allows the state to be preserved across renders, ensuring consistency and reactivity.

Setting State: The setState function provides a mechanism to update the state. It accepts a new state value or a function that returns a new state value based on the current state. This flexibility supports both direct state updates and updates based on the previous state, mirroring React’s useState functionality.

Re-render Triggering: Upon updating the state, setState calls component.requestUpdate(), which is a LitElement method that requests the component to re-render. This process ensures that changes in state are reflected in the component’s UI, maintaining a reactive data flow.

State Access and Modification: The function returns a tuple containing a getter function for accessing the current state and the setState function for updating it. This pattern provides a concise and intuitive interface for state management within the component.

Practical Implementation

Integrating the useState function into a Lit-based web component enables developers to manage state with ease and efficiency. Here’s a simple example demonstrating its usage:

import { html } from "lit";
import { define, useState } from "./custom-hooks";

function MyFunctionalComponent({ component }) {
const [count, setCount] = useState(0, component, "count");

return html`
<div>
<p>Count: ${count()}</p>
<button @click=${() => setCount(count() + 1)}>Increment</button>
</div>
`;
}

// Define the component
define({
tag: "my-counter",
component: MyFunctionalComponent,
});

In this example, useState is used to declare a count state variable within MyFunctionalComponent. The state can be accessed and updated using the getter count() and the setter setCount, respectively, facilitating a reactive and declarative approach to state management.

Creating a Custom useEffect Hook

Expanding the functional programming paradigm within web components, we can create a custom useEffect function, inspired by the React hook of the same name. This function enables the execution of side effects in response to changes in specific dependencies, a critical feature for managing side effects like data fetching, subscriptions, or manually manipulating the DOM in a controlled manner. The introduction of useEffect into the realm of web components, particularly those utilizing the Lit library, marks a significant step toward aligning these components with reactive programming principles and enhancing their interactivity and responsiveness.

export function useEffect(effectCallback, dependencies, component, id) {
const effectPropName = `effect-${id}`;

// Initialize or update the dependencies property
const hasChangedDependencies = component[effectPropName]
? !dependencies.every(
(dep, i) => dep === component[effectPropName].dependencies[i]
)
: true;

if (hasChangedDependencies) {
// Update dependencies
component[effectPropName] = {
dependencies,
cleanup: undefined, // Placeholder for cleanup function
};

// Call the effect callback and store any cleanup function
const cleanup = effectCallback();
if (typeof cleanup === "function") {
component[effectPropName].cleanup = cleanup;
}
}

// Integrate with LitElement lifecycle for cleanup
component.addController({
hostDisconnected() {
if (component[effectPropName]?.cleanup) {
component[effectPropName].cleanup();
}
},
});
}

Understanding the useEffect Function

The useEffect function is designed to watch for changes in a specified set of dependencies and execute a callback function (effectCallback) when any of those dependencies change. This mechanism allows developers to encapsulate side-effect logic in a declarative and isolated manner, improving code organization and reusability. Here’s how it works:

Effect Identification: Each effect is associated with a unique id to prevent conflicts and ensure correct dependency tracking. The effect’s metadata, including its dependencies and any cleanup function, is stored on the component instance using a property named effect-${id}.

Dependency Tracking: Upon invocation, useEffect checks whether the dependencies have changed since the last render by comparing the current dependencies with the previously stored ones. This check ensures that the effect callback is only executed when necessary, optimizing performance and preventing unnecessary side effects.

Executing the Effect Callback: If the dependencies have changed, the function executes the effectCallback. This callback can optionally return a cleanup function, which is designed to perform any necessary cleanup actions when the component is destroyed or when the effect needs to re-run due to dependency changes.

Cleanup Function Management: The returned cleanup function, if any, is stored within the effect’s metadata on the component. This function is called to clean up the previous effect before executing the effect callback again or when the component is disconnected from the DOM, ensuring that side effects are managed cleanly and efficiently.

Lifecycle Integration: useEffect integrates with the LitElement lifecycle by utilizing the addController method. This integration ensures that cleanup functions are called at the appropriate time, specifically when the component is disconnected from the DOM, preventing memory leaks and other side effect-related issues.

Example Usage

The useEffect function can significantly enhance the functionality of Lit-based components by allowing for efficient side effect management. Here’s a simple example to illustrate its usage:

import { html } from "lit";
import { define, useEffect } from "./custom-hooks";

function MyFunctionalComponent({ component }) {
useEffect(
() => {
// Side effect logic here, e.g., fetching data or setting up a subscription
const interval = setInterval(
() => console.log("This logs every second"),
1000
);

// Cleanup function
return () => clearInterval(interval);
},
[],
component,
"intervalEffect"
);

return html`<p>Check the console to see the effect in action.</p>`;
}

// Define the component
define({
tag: "my-effect-component",
component: MyFunctionalComponent,
});

In this example, useEffect is used to set up a timer that logs a message to the console every second. The empty dependencies array ([]) indicates that the effect should run once when the component mounts, and the cleanup function clears the interval when the component unmounts or the effect needs to re-run, showcasing how to manage side effects cleanly and efficiently in a functional component.

Creating a custom useMemo Hook

Optimizing performance in web applications often involves minimizing unnecessary computations, especially those that are costly and do not need to be recalculated on every render. Inspired by React's useMemo hook, we can introduce a custom useMemo function tailored for web components. This function is particularly useful in scenarios where certain calculations are dependent on specific values and only need to be recomputed when those values change. By leveraging useMemo, developers can ensure that their components remain efficient and responsive, even in complex applications.

export function useMemo(calculation, dependencies, component, id) {
const memoPropName = `memo-${id}`;

// Check if the memoized value and dependencies exist
if (!component[memoPropName]) {
component[memoPropName] = {
dependencies: [],
value: undefined,
};
}

const hasChangedDependencies = !dependencies.every(
(dep, index) => dep === component[memoPropName].dependencies[index]
);

// If dependencies have changed or this is the first run, recalculate the memoized value
if (hasChangedDependencies) {
component[memoPropName].value = calculation();
component[memoPropName].dependencies = dependencies;
}

return component[memoPropName].value;
}

The Mechanics of useMemo

The useMemo function is designed to memoize or cache a computed value based on a set of dependencies. It checks if the dependencies have changed since the last computation; if they haven’t, it returns the cached value instead of recalculating it. This mechanism significantly reduces the performance overhead for expensive calculations that depend on specific props or state but don’t need to be run on every component update. Here’s an in-depth look at how useMemo operates:

Memoization Setup: Upon invocation, useMemo initializes or updates a memoization object on the component instance, identified by a unique id. This object stores the memoized value and its dependencies.

Dependency Check: It then determines whether the dependencies have changed since the last time the memoized value was calculated. This check is crucial for deciding whether to reuse the cached value or compute a new one.

Recomputing the Value: If any dependency has changed (or if it’s the first run), useMemo recalculates the value by executing the provided calculation function. The newly computed value is stored along with the current dependencies for future reference.

Returning the Memoized Value: Finally, useMemo returns the current memoized value, whether it was just recalculated or retrieved from the cache. This value can then be used within the component without the performance penalty of unnecessary recalculations.

Example Implementation

Here’s how you might use the useMemo function within a Lit-based component to optimize performance:

import { html } from "lit";
import { define, useMemo } from "./custom-hooks";

function ExpensiveComponent({ prop1, prop2, component }) {
// A hypothetical expensive calculation that depends on prop1 and prop2
const expensiveComputationValue = useMemo(
() => {
console.log("Recalculating expensive value");
return prop1 + prop2; // Replace with actual expensive operation
},
[prop1, prop2],
component,
"expensiveCalc"
);

return html`<p>
The expensive computation value is: ${expensiveComputationValue}
</p>`;
}

// Define the component
define({
tag: "my-expensive-component",
component: ExpensiveComponent,
});

In this example, useMemo is used to cache the result of an expensive calculation that only needs to be recomputed when prop1 or prop2 changes. This approach ensures that the calculation is only performed when necessary, preserving component performance and responsiveness.

Refinement

You may have noticed a slight inconsistency in the custom hooks we've defined so far when compared to React's hooks. In React, hooks dont require the component instance or a hook id to manage state, effects, or memoized values. This is because React's hooks are designed to work within the context of a functional component, where the component instance is implicitly available. However, in the case of Lit-based web components, which are class-based, we pass the component instance and a unique id to our custom hooks to ensure proper encapsulation and isolation of state, effects, and memoized values. Lets refine our custom hooks to more closely resemble React's hooks by leveraging a context-based approach. we will start by creating some scoped context to store the component instance and hook id.

import { LitElement } from "lit";

// Scoped context to store the component instance and hook id
let currentComponent = {};
let hookIndex = 0;

export function define({ tag, component: CustomFuntionalComponent }) {
class CustomComponent extends LitElement {
render() {
// get all attributes
const attributes = Array.from(this.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {});

const functionalComponent = () =>
CustomFuntionalComponent({
...attributes,
children: this.innerHTML,
});

currentComponent = this;
hookIndex = 0;

return functionalComponent();
}
}
window.customElements.define(tag, CustomComponent);
}

In this updated version of the define function, we've introduced two scoped variables, currentComponent and hookIndex, to store the component instance and the current hook index, respectively. These variables are set before rendering the functional component, ensuring that the necessary context is available for our custom hooks to access the component instance and manage hook ids. This context-based approach aligns more closely with React's hooks model, where the component instance is implicitly available within functional components.

Next, we'll update our custom hooks to leverage this context-based approach, removing the need to pass the component instance and hook id explicitly. here is an example of the updated useState hook (you can update the other hooks similarly):

export function useState(initialState) {
// note: hookIndex is incremented to ensure uniqueness
const propName = `hook-${hookIndex++}`;

currentComponent[propName] = currentComponent[propName] ?? initialState;

const setState = (newState) => {
const currentValue = currentComponent[propName];
const newValue =
typeof newState === "function" ? newState(currentValue) : newState;

currentComponent[propName] = newValue;
currentComponent.requestUpdate();
};

return [() => currentComponent[propName], setState];
}

Scoped components

Its important in web developerment that developers can compose their components in a way that they can be reused in different contexts. When using customElements , the shadow dom is a powerful tool to encapsulate the styling and behavior of a component. However, sometimes we want to share styles and behavior across multiple components.

When investigating how Lit addresses this scoping issue, we come across an implementation from open web component. It introduces a decorator ScopedElementsMixin to use with Lit which will enable developers to specify which elements are scoped to a component.

Lets create a useScope hook that will allow us to define scoped elements in a more functional way.

export function useScope(elements) {
Object.keys(elements).forEach((key) => {
const elementClass = elements[key];

// Define the custom element with a unique tag per component instance
if (!customElements.get(key)) {
define({ tag: key, component: elementClass });
}
});
}

Practical Usage

useScope({
"some-button": Button,
"some-textfield": Textfield,
})

...

render() {
return html`
<some-button></some-button>
<some-textfield></some-textfield>
`;
}

The useScope Hook Explained

The useScope function takes an object called elements as its parameter. This object contains key-value pairs where each key represents the tag name of the custom element, and the corresponding value is the element's class (the custom element definition). This allows the function to programmatically register multiple custom elements within a single component. Inside the loop, it checks whether the custom element with the specified key (tag name) has already been defined in the customElements registry using customElements.get(key). If it hasn't been defined, it calls define() (as created in my previous article) to register the custom element with a unique tag for this instance of the component. The define function is the same one we created in the previous article to define custom elements.

The key idea here is that by dynamically registering custom elements in this scoped manner, developers can create reusable components without worrying about naming conflicts or polluting the global custom elements registry. Each custom element can be scoped specifically to the component using it, allowing for greater flexibility and modularity across different parts of the application.

Styled Components

Now lets create a solution to how web components handle styling. When we look into how Lit elements handles styling, we see styles are applied via the static styles property, which is evaluated when the class is first instantiated, making it tricky to dynamically inject styles in a purely functional way after the component is loaded. Updating the styles property after it’s loaded won’t work because Lit doesn’t observe or apply styles assigned to an instance-level styles property.

To make a functional approach to this, we need to use the component’s shadow root and inject styles into it directly.

export function useStyle(styles) {
const component = currentInstance;

if (!component._stylesApplied) {
component._stylesApplied = true;

// Apply the styles to the component
const styleElement = document.createElement("style");
styleElement.textContent = unsafeCSS(styles).cssText;
component.shadowRoot.appendChild(styleElement);
}
}

Practical Usage

useStyle(css`
.button {
background-color: blue;
color: white;
padding: 10px;
border: none;
border-radius: 5px;
cursor: pointer;
}
`);

...

render() {
return html`
<button class="button">Click me</button>
`;
}

The useStyle Hook Explained


The useStyle function takes a CSSResult object as its parameter, which is generated using the css function from the lit-element library. This CSSResult object contains the CSS rules that need to be applied to the component.

Inside the function, we first get a reference to the current component instance using the currentInstance property. This allows us to access the shadow root of the component and inject styles directly into it.

We then check if the _stylesApplied property of the component is false, indicating that the styles have not been applied yet. If this is the case, we set _stylesApplied to true to prevent the styles from being applied multiple times.

Next, we create a new style element using document.createElement(‘style’) and set its text content to the CSS rules provided in the styles parameter. We convert the CSSResult object to a string using the unsafeCSS function from the lit-element library to ensure that the styles are safe to inject into the shadow root.

Finally, we append the style element to the shadow root of the component, effectively injecting the styles into the component’s shadow DOM. This approach allows developers to dynamically apply styles to a component in a functional way, ensuring that the styles are encapsulated within the component and do not leak out to other parts of the application.

Functional module federation

Now lets create a solution to how web components can handle module federation. We can leverage the promise functions to load external components dynamically at runtime.

A common concern with module federation and microfrontends is how to manage dependencies. Webpack allows for shared dependencies to be specified in the configuration. In our case, we can create a hook that will load the dependencies and register the custom elements asynchronously.

export const useLazyScope = (tag, promise) => {
promise.then((module) => {
const elementClass = new Function(`return ${module}`)();

if (!customElements.get(tag)) {
define({ tag, component: elementClass });
}
});
};

Practical Usage

useLazyScope("lazy-button", new Promise((resolve) => {
setTimeout(() => {
resolve(Button.toString());
}, 2000);
}));

...

render() {
return html`
<lazy-button></lazy-button>
`;
}

The useLazyScope Hook Explained

The useLazyScope function takes two parameters:

  • tag — which is the tag name of the custom element to be registered
  • a promise function — where the return value is expected to be a javascript function serialized into a string. (In practice you would want this to be something like a vanilla fetch call to get the static from a remote url). It could look something like:
const functionAsString = `({ children })=>{
return html`
<button>
${children}
</button>
`;
}`;

In this example, we are using the new Function constructor to create a new function from the module string. This allows us to dynamically load the custom element's class from the module at runtime and construct a functional component from it. A limitation with this implementation, is if we want to use hooks or other imports in the module, we would need to ensure that the module is bundled with all its dependencies or available in the scope.

We can add these dependencies into scope by passing an object to the define() method we created in the previous article. We will update the method to allow us pass in any shared dependencies we might want:

export function define({ tag, component: CustomFuntionalComponent }) {
class CustomComponent extends LitElement {
constructor() {
super();
this.hookIndex = 0;
this.hooks = {};
}

render() {
// get all attributes
const attributes = Array.from(this.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {});

const sharedDependencies = {
useState,
useEffect,
useMemo,
useScope,
useStyle,
html,
css,
};

this.hookIndex = 0;
currentComponent = this;
const result = CustomFunctionalComponent(
{
...attributes,
children: this.innerHTML,
},
sharedDependencies
);
currentComponent = null;

return result;
}
}

window.customElements.define(tag, CustomComponent);
}

This could enable us to share hooks and other dependencies between components. the sharedDependencies object contains the hooks and functions that are shared between components. It might no be the most elegant solution, but by adding the sharedDependencies object to the define method, we can ensure that the hooks are available to all components that are defined using this method. The following is an example of a button component with hooks as sharedDependencies.

const ButtonAsAString = `({ children, initialstate = 0 }, { useState, useEffect, useMemo, useStyle, html, css }) => {
...
}`;

useLazyScope("lazy-button", new Promise((resolve) => {
setTimeout(() => {
resolve(ButtonAsAString);
}, 2000);
}));

...

render() {
return html`
<lazy-button initialstate="10">Click me</lazy-button>
`;
}const Button = ({ children }) => {
const [clicked, setClicked] = useState(false);

return html`
<button @click=${() => setClicked(true)}>${children}</button>
`;
}

While this approach is not perfect for all use cases, it provides a starting point for dynamically loading custom elements with shared dependencies and optimizing the loading process to suit their specific requirements.

Conclusion

This article has explored how to create functional web components with scoped elements, dynamic styling, and module federation. By leveraging the power of hooks and functional programming principles.

Its crucial to note that this is a proof of concept and not sutable for any production use case. This article aims to illustrate the possibilities of creating functional web components with Lit and how developers can experiment with different approaches to building web interfaces.