Lit makes it easy to create web components – reusable HTML elements with shared logic. However, different elements often have similar behaviors, and creating another element just for sharing a behavior may be excessive.
Reactive Controllers can help the problem of sharing logic across components without having to create a new web component. They are similar to custom hooks in React, and in this article, we will use them to integrate the state manager Redux with Lit's rendering lifecycle for a more self-contained, composable, idiomatic Lit experience.
By the end of this article, you will learn how to use Reactive Controllers to integrate third party libraries into Lit by integrating Redux into Lit. To do this, we will create a Reactive Controller that selects part of a Redux state and updates a component whenever the state updates.
Reactive Controllers are a programming pattern that makes it easy to share logic between components by hooking into a component’s reactive update lifecycle. They achieve this by expecting an object that exposes an interface rather than having to create a new component or subclassing like you would with a mixin.
One advantage of the Reactive Controller pattern is that it creates a with relationship rather than an is relationship. For example, a component that uses a Reactive Controller that incorporates Redux logic is a component with Redux selector abilities, whereas a mixin that does the same would mean that the component is a Redux selector component. This type of composability results in code that is more portable, self-contained, and easier to refactor. This is because components that inherit via subclassing are more closely coupled with the logic they inherit.
Reactive Controllers are just an interface – a pattern, which makes them easier to use with other component systems without committing to a specific architecture. This makes it possible to create Reactive Controller adapters that work with other frameworks and libraries, such as React, Vue, Angular, and Svelte.
Redux is a mature library that introduces patterns to manage state across a JavaScript application. Redux currently does not have much new adoption and is not endorsed by the Lit team as a solution for all state management needs. Despite this, we will be using Redux as an example for creating a Reactive Controller, because the patterns used in integrating Redux into Lit with a Reactive Controller may be used to integrate for other popular libraries.
There are generally three concepts that Redux introduces:
Stores
Actions
Reducers
Stores are essentially stores of your current state. Actions are actions you would like to perform on the state, and reducers take actions and apply them to the current state to return a new state. Here is a diagram derived from the official Redux documentation that depicts the interaction pattern between these concepts:
The UI has an interaction that triggers an event handler
The event handler dispatches an Action to the store
A reducer takes the current state and the action and computes the new state
The reducer updates the state in the store
The UI is updated with the newest state with a state subscription and, in our case, a Reactive Controller
Lit would cover the UI (blue) section of this diagram – rendering and event handling. Redux would handle the orange, green, and red parts of this diagram. The example in this article is to create a Reactive controller that handles the interaction between the updated state and the UI by hooking into both Lit’s reactive update lifecycle and Redux’s state updates.
The Reactive Controller package has two interfaces: one for the controller, ReactiveController, and one for the host that it is hooking into, ReactiveControllerHost.
In Lit hostConnected() is called when the host component is placed in the DOM or if the element is already placed in the DOM and the Reactive Controller was just attached. This is a good place to do initialization work when the host component is ready to be used such as adding event listeners.
Similarly hostDisconnected() is called when the element is removed from the DOM. This is a good place to do some cleanup work such as removing event listeners.
hostUpdate() is called before the element is about to render or re-render. This is a good place to synchronize or compute state before rendering.
hostUpdated() is called after an element has just rendered or re-rendered. This is a good place to synchronize or compute a state that is reliant on rendered DOM. It is often discouraged to request an update to the host in this part of the lifecycle unless absolutely necessary as it may cause an unnecessary re-render of the component just after it has already rendered. Request host updates in hostUpdated() only when hostUpdate() cannot be utilized.
Reactive Controllers typically have access to an instance of an object that implements the ReactiveControllerHost interface, which is often passed to them upon initialization. This allows the Reactive Controller to attach itself to the host and request that it update and re-render.
The ReactiveControllerHost interface exposes three methods and one property:
The addController() method takes in the controller that you want to hook into the host’s lifecycle.
If the host is already attached to the DOM or rendered onto the page, then hostConnected() will be called after attaching the Reactive Controller via addController().
A common pattern in Lit is to attach the current instance of the controller to the host in the constructor() of the ReactiveController. For example:
The removeController() method is used less frequently than the other callbacks. It is useful when you do not want the controller to update with the host, such as: the host updates too often, the hostUpdate[d]() methods have slow or expensive logic, or you do not need the controller to run its updates while the component has been removed from the document.
The requestUpdate() method is used to request the host component to re-run its update lifecycle and re-render. This is often called when the controller has a value that updates and should be reflected in the DOM. For example, the @lit/task package’s Task controller will do asynchronous work like fetching data or asynchronous rendering, and it calls the host’s requestUpdate() method to reflect that the state of the task has changed to pending, in progress, completed, or error which should be rendered in the component.
The read-only updateComplete property is often used in conjunction with requestUpdate() method. A ReactiveControllerHost’s update lifecycle is assumed to be asynchronous, so the updateComplete property is a promise that resolves when the host’s update lifecycle has completed. This is useful for controllers that need to update the DOM and then read from it. For example, imagine a controller that resizes a DOM element and needs to then read its new dimensions. This controller would update a property, call requestUpdate(), await host.updateComplete, and then read the DOM.
Redux has a bit of verbosity associated with it in order to enforce the Redux state management patterns. In this article we will be making a simple component that renders circles and squares, renders how many of them exist, and can increment or decrement the amount of circles and squares. This component will have its state managed by Redux.
Next we will write the reducer which will define which types of actions this store will be able to accept as well as determine how to update the state. Our reducer will have the following actions:
increment_circles
increment_squares
decrement_circles
decrement_squares
reset
Here is how the reducer could look like in the store. file:
As demonstrated when initializing the state of the store, we can dispatch actions to the store using the store.dispatch(action) method. Let us create a shape-dials element that has circle and square increment buttons as well as a reset button. And upon click, will dispatch the appropriate actions:
Now we have our state managed by Redux and are dispatching actions to the store. The store is updating its state using the reducer.
Next we need to give some elements the ability to connect and subscribe to changes to the store and update the UI. On top of that we will write a “Selector” which will select a specific datum from the overall state, and if that value changes, we will tell the Reactive Controller host to re-render.
First we will write the definition of the SelectorController class and attach it to the host element so that the Reactive Controller can hook into the host’s reactive update lifecycle:
Next, let's accept the following options: the Redux store in which we would like to subscribe to, as well as the Selector we would like to use to select what data we would like to render from the store:
Now let’s initialize the initially selected value so that the user can access the state’s initial value with selectorController.selected using the user's selector:
We now have a controller that initializes to the initial state of the store, next let’s update this.selected when the state updates and then tell the host element to re-render when we have detected a change in the selected value.
Redux stores have a Store.subscribe(listener) method which will call a given callback whenever the state of the store updates. Let's hook into this, update this.selected, and tell the host to update when the component is connected to the DOM:
Great! Now the controller will update its value and tell the host element to update whenever the state changes. A problem we may encounter here is that we will call host.requestUpdate() whenever any state changes and not specifically when our selected value changes. In this case, we should do an equality check and let the user decide if they would like to implement their own equality check:
if (!this.equalityCheck(this.selected, selected)) {
this.selected=selected;
this.host.requestUpdate();
}
});
}
}
By first comparing the previous state to the current state, we can avoid re-rendering components that don't need to be re-rendered which can improve performance. Nice!
In conclusion, we need to improve our component so that it does not re-render when the component is disconnected from the page and the store’s state changes. Redux’s Store.subscribe() method returns an unsubscribe() function. Let’s keep track of this and unsubscribe from the store’s changes when the component disconnects.
...
exportclassSelectorController<
State,
ActionextendsRAction,
Result=unknown
> implementsReactiveController {
...
unsubscribe: () =>void;
...
hostConnected() {
this.unsubscribe=this.store.subscribe(() => {
...
});
}
hostDisconnected() {
this.unsubscribe();
}
}
We now have a Redux SelectorController Reactive Controller that listens to a Redux store for state changes, selects a value from the state, and updates the host element whenever that state value changes!
shape-list should be a component that only subscribes and reads the state.shapeList state from the store. Let's create a custom element with boilerplate, and render an array of <div>s with classes set to the shape name. Our pre-provided CSS styles will render the squares and circles based on the class name.
import { customElement } from'lit/decorators.js';
import { LitElement, html} from'lit';
@customElement('shape-list')
exportclassShapeListextendsLitElement {
render() {
constshapeList= [];
returnshapeList.map(
shape=>html`<divclass="${shape}"></div>`
);
}
}
Next, let’s initialize our SelectorController to hook into Redux and render the shapeList:
Finally, let’s give it an equality check to only update the render when the values have changed.
...
@customElement('shape-list')
exportclassShapeListextendsLitElement {
privatesc=newSelectorController(
this,
store,
(state) =>state.shapeList,
(oldVal, newVal) => {
if (oldVal.length!==newVal.length) {
returnfalse;
}
return!oldVal.some((old, i) => {
returnold!==newVal[i];
});
}
);
...
}
The shape-list component should now be responsive to changes in the Redux store!
We were able to accomplish this by initializing the SelectorController with the shared Redux store. We then selected only the shapeList from the state and updated the host element only when the arrays were truly not equal.
To prevent invalid inputs, we will use our SelectorController to disable the buttons in the shape-dials component. For example, we want to disable the decrement buttons when the respective shape count is 0, and we want to disable the reset button when the length of the shapeList is 0.
We will be using the entire state object again, so the selector will be broad. Let’s add the SelectorController to our shape-dials component.
SelectorController is a simple integration. It only handles selectors, but it could easily abstract more of Redux into the controller, such as automatically dispatching actions when a property changes. Reactive Controllers give you the freedom to abstract as little or as much of a library as you want.
Reactive Controllers are useful for integrating state managers because it is common to want to update the view whenever the state changes or update the state manager when the UI changes.
A great example of using Reactive Controllers for state management is the Apollo Element’s Apollo Query Reactive Controllers for Apollo GraphQL. Reactive Controllers can fit nicely in other similar projects like MobX, RxJS, or Zustand.
Reactive Controllers are also useful for integrating libraries that fetch data and need to synchronize with Lit’s rendering or update lifecycle.
For example, the @lit/task library can perform simple async logic and easily render results and status. Reactive Controllers can fit nicely in other similar projects such as Axios, TanStack Query, or the experimental @lit-labs/router.
Reactive Controllers are generally a good way to share logic across components and we cannot cover every possible use case here. For example Material Design’s Material Components have written their own bespoke controllers such as the SurfacePositionController which can position a popup surface next to an anchor or TypeaheadController which can automatically select an item from a list just by typing the first few letters of the item like an autocomplete.
Reactive Controllers are flexible, focused, and a great way to integrate libraries into your Lit project or any framework of your choice.