In the ever-evolving landscape of web development, the quest for efficient, scalable, and easy-to-maintain solutions has led to the emergence of a myriad of frameworks and libraries, each promising a unique blend of features and capabilities. Among these, the simplicity and power of web components, especially as offered by the Lit library, stand out as a beacon for developers seeking a lightweight yet robust approach to building interactive web interfaces. Web components represent a set of web platform APIs that allow developers to create new, reusable, encapsulated HTML tags for use in web pages.
The recent surge in the popularity of modern frameworks like React, with its innovative hooks system, and Vue, with its intuitive component model, has shifted the focus towards a more functional and declarative approach to defining components and managing state. These frameworks have democratized the creation of dynamic web applications, making it accessible to a broader range of developers, from beginners to seasoned experts. However, amidst this paradigm shift, an intriguing question arises: what are the possibilities with web components?
The functional approach to web development offers a comprehensive set of advantages that cater to the needs of modern web applications. By promoting reusability, enhancing testability, simplifying state management, encouraging declarative code, optimizing performance, and ensuring flexibility, this approach not only improves the development experience but also results in applications that are robust, maintainable, and scalable. As the web continues to evolve, the principles of functional programming remains a guiding light for developers seeking to build efficient and effective web solutions.
In this article, we will create a set of custom hooks that mimic the behavior of React’s hooks within the context of Lit-based web components. At the end we will have a todo list application built with Lit and our custom hooks.
import { html } from "lit";
import { define, useState, useEffect, useMemo } from "./custom-hooks";
const Todo = () => {
const [todos, setTodos] = useState([]);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
console.log("Todo mounted");
return () => {
console.log("Todo unmounted");
};
}, []);
useEffect(() => {
console.log("Todos changed");
}, [todos]);
const numberOfTodoItems = useMemo(() => {
console.log("memo calculation triggered");
return todos.length;
}, [todos]);
const addTodo = () => {
setTodos([...todos, inputValue]);
setInputValue('');
}
const removeTodo = (index) => {
setTodos(todos.filter((_, i) => i !== index));
}
return html`
<div>
<h1>Todo App</h1>
<input type="text" placeholder="Add todo" .value="${inputValue}" @input="${(e) => setInputValue(e.target.value)}" />
<button @click="${addTodo}">
Add
</button>
<p>
Number of todo items: ${numberOfTodoItems}
</p>
<ul>
${todos.map((todo, index) => html`
<li key="${index}">
${todo}
<button @click="${() => removeTodo(index)}">
Remove
</button>
</li>
`)}
</ul>
</div>
`;
}
define({ tag: "todo-app", component: Todo });
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
he 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.
export function useState(initialState, component, id) {
// Define a unique property name for each state variable
const propName = `state-${id}`;
component[propName] = component[propName] ?? initialState;
const setState = (newState) => {
const currentValue = component[propName];
const newValue = typeof newState === 'function' ? newState(currentValue) : newState;
component[propName] = newValue;
component.requestUpdate();
};
return [() => component[propName], setState];
}
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];
}
Conclusion
Throughout this exploration, we’ve delved into the integration of functional programming patterns within the context of Lit, a modern library for building web components. By drawing inspiration from React’s hooks, we’ve introduced custom functions like define, useState, useEffect, and useMemo. These functions embody the spirit of functional programming, offering a way to manage state, side effects, and optimizations in a reactive and efficient manner, all while working within the framework of web components.
It’s crucial to note that the code and concepts presented in this article serve as a proof of concept rather than the foundation of a fully-realized UI framework. The primary goal has been to illustrate the potential for incorporating functional programming paradigms into web component development, highlighting how these patterns can enhance the developer experience and improve application performance. The custom hooks we’ve discussed are designed to spark ideas and encourage experimentation with functional patterns in web development projects, especially those leveraging the Lit library.
As developers, our journey is one of continuous learning and adaptation. The exploration of functional patterns within the realm of web components is a testament to the vibrant evolution of web development practices. By experimenting with these concepts, we can uncover new methodologies that not only streamline our development processes but also elevate the quality of our applications.
I extend my heartfelt thanks to all readers who have joined me on this exploration. Your curiosity, enthusiasm, and commitment to advancing your skills are what drive the continuous evolution of web development. The journey doesn’t end here; it’s an invitation to further innovate, experiment, and discover the vast possibilities that lie at the intersection of functional programming and web component technology. Let’s continue to push the boundaries of what’s possible, armed with the knowledge and insights we’ve gained together.
As we move forward, I encourage you to take these concepts and experiment with them in your projects. The landscape of web development is ever-changing, and by embracing new ideas and approaches, we enrich not only our own development practices but also contribute to the growth and diversity of the community at large. Thank you for embarking on this journey with me, and here’s to the many more discoveries we’ll make together in the ever-evolving world of web development.