Redux + Lit with Reactive Controllers

Photo of Elliott Marquez
Elliott Marquez
Photo of Brian Taylor Vann
Brian Taylor Vann

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 with 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. For those familiar with React, Reactive Controllers are similar to custom hooks, while mixins are analogous to higher-order components.

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.

two rectangle wire diagrams. The left is labeled 'Mixin composition' the right 'Reactive Controller Composition'. There is a key that says blue lines are instance properties and green lines are class / superclasses. The mixin composition is a green rectangle labeled 'SideDrawerElement' with a co-centric green rectangle inside of it labeled 'AsyncTaskElement()`, with another co-centric green rectangle labeled 'ReduxSelectorElement()', with a final co-centric green rectangle labeled 'LitElement'. The Reactive controller diagram is a green rectable labeled 'SideDrawerElement' with three rectangles inside of it. They are: a blue dashed rectangle labeled 'ReduxSelectorController' and another one labeled 'AsyncTaskController'. The third rectangle is a green rectangle labeled 'LitElement'.

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. The Lit team does not have a particular endorsement for a single state management library, but 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 three general concepts that Redux describes:

  1. Stores
  2. Actions
  3. Reducers

Stores contain the current application state. Actions describe what kind of change to make to 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](redux.js.org/tutorials/essentials/part-1-overview-concepts#redux-application-data-flow ?) that depicts the interaction pattern between these concepts:

A vector box diagram of the Redux cycle that is split into blue-toned, green-toned, and orange-toned boxes and arrows. At the bottom is a blue box that says UI. There is a blue arrow with the blue label 'event'. That points to a blue box called 'Event Handler'. At the top boundary of the Event Handler box is an orange box labeled 'Action Dispatcher'. From this orange box is an orange arrow labeled 'Action' pointing inside another orange box labeled Store. The orage Store box has a nested green box inside of it labeled Reducer which has 3, nested, little, green squares each labeled R. On the bottom edge of the orange Store box is a red box that says 'State'. The Orange Action arrow that was pointing inside the Orange Store box is pointing to the top of a green Reducer box. Out of the bottom of the green reducer box is another orange arrow pointing to the red 'State' box, and this arrow is labeled 'Updated State'. Out of the red State box are two orange arrows. The first orange arrow is inside the orange Store box pointing back to the top of the green reducer box and is labeled 'Old State'. The other orange arrow pointing out of the 'State' box turns into a blue arrow and is pointing to the original blue 'UI' box. The orange part of this arrow is labeled 'Subscription' in orange, and the blue part of the arrow is labeled 'Reactive Controller' in blue.

  1. The UI has an interaction that triggers an event handler
  2. The event handler dispatches an Action to the store
  3. A reducer takes the current state and the action and computes the new state
  4. The reducer updates the state in the store
  5. 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.

The ReactiveController interface exposes four methods:

In Lit, hostConnected() is called when the host component is placed in the DOM, or, if the component is already placed in the DOM, when the Reactive Controller is attached to the component. 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 and add guards against infinite re-renders.

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. Lit elements implement this so they can serve as Reactive Controller hosts, but the Reactive Controller pattern is not exclusive to Lit.

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 – it is recommended that implementations of ReactiveControllerHost call hostConnected() 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() or hostUpdated() 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.

A vector box diagram of the entire redux application. There are 3 sections. The first is the shape-dials component – it has 3 rows. The first row is a minus button, the label 'squares', followed by a plus button. The second row is a minus button, the label 'circles', followed by a plus button. The final row is a long 'reset' button. The next section to the right of the shape-dials section the '<shape-count>' component. It is a black box with 3 rows of text. The first row is '2 circles'. The second row is '2 squares', and the third row is '4 total'. Below the shape-dials and shape-count components is a long rectangle which is the '<shape-list>' section. This is a long, horizonal, black rectangle and inside of it is a red square followed by a blue circle, blue circle, and another red square.

We need to define an initial state, so in a store. file we will give the initial state the following shape:

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:

  • incrementShape()
  • decrementShape()
  • resetShapes()

Here is how the reducer could look like in the store. file:

Next we will create the store and initialize it with the reducer created by the shapeSlice:

We have now successfully created a Redux store that has an initial state and can update its state using the reducer.

A vector box diagram of the Redux cycle that is split into blue-toned, green-toned, and orange-toned boxes and arrows. At the bottom is a blue box that says UI. There is a blue arrow with the blue label 'event'. That points to a blue box called 'Event Handler'. At the top boundary of the Event Handler box is an orange box labeled 'Action Dispatcher'. From this orange box is an orange arrow labeled 'Action' pointing inside another orange box labeled Store. The orage Store box has a nested green box inside of it labeled Reducer which has 3, nested, little, green squares each labeled R. On the bottom edge of the orange Store box is a red box that says 'State'. The Orange Action arrow that was pointing inside the Orange Store box is pointing to the top of a green Reducer box. Out of the bottom of the green reducer box is another orange arrow pointing to the red 'State' box, and this arrow is labeled 'Updated State'. Out of the red State box are two orange arrows. The first orange arrow is inside the orange Store box pointing back to the top of the green reducer box and is labeled 'Old State'. The other orange arrow pointing out of the 'State' box turns into a blue arrow and is pointing to the original blue 'UI' box. The orange part of this arrow is labeled 'Subscription' in orange, and the blue part of the arrow is labeled 'Reactive Controller' in blue. The store is enclosed in a dashed green line that has a key that labels that section as 'Completed'

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:

A vector box diagram of the shape-dials component. There are 3 rows. The first row is a minus button, the label 'squares', followed by a plus button. The second row is a minus button, the label 'circles', followed by a plus button. The final row is a long 'reset' button.

Writing the SelectorController Reactive Controller

Permalink to “Writing the SelectorController Reactive Controller”

Now we have our state managed by Redux and are dispatching actions to the store. The store is updating its state using the reducer.

A vector box diagram of the Redux cycle that is split into blue-toned, green-toned, and orange-toned boxes and arrows. At the bottom is a blue box that says UI. There is a blue arrow with the blue label 'event'. That points to a blue box called 'Event Handler'. At the top boundary of the Event Handler box is an orange box labeled 'Action Dispatcher'. From this orange box is an orange arrow labeled 'Action' pointing inside another orange box labeled Store. The orage Store box has a nested green box inside of it labeled Reducer which has 3, nested, little, green squares each labeled R. On the bottom edge of the orange Store box is a red box that says 'State'. The Orange Action arrow that was pointing inside the Orange Store box is pointing to the top of a green Reducer box. Out of the bottom of the green reducer box is another orange arrow pointing to the red 'State' box, and this arrow is labeled 'Updated State'. Out of the red State box are two orange arrows. The first orange arrow is inside the orange Store box pointing back to the top of the green reducer box and is labeled 'Old State'. The other orange arrow pointing out of the 'State' box turns into a blue arrow and is pointing to the original blue 'UI' box. The orange part of this arrow is labeled 'Subscription' in orange, and the blue part of the arrow is labeled 'Reactive Controller' in blue. Everything but the orange-blue arrow is enclosed in a dashed green line that has a key that labels that section as 'Completed'

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.

Attaching the Controller and Accepting options

Permalink to “Attaching the Controller and Accepting options”

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 pick out from the store:

Now let’s initialize the initially selected value so that the user can access the state’s initial value with selectorController.value using the user's selector:

We now have a controller that initializes to the initial state of the store, next let’s update this.value 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.value, 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. Additionally, 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.

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!

Now that we have a functioning controller, let’s use it! Let’s create two components and update our shape dial’s controls:

  1. shape-count
    • A component that counts the shapes and the total number of shapes
  2. shape-list
    • A component that visualizes the shapes that we have added

And finally we will update shape-dials to disable the buttons when they are not applicable. The application should look something like this:

A vector box diagram of the entire redux application. There are 3 sections. The first is the shape-dials component – it has 3 rows. The first row is a minus button, the label 'squares', followed by a plus button. The second row is a minus button, the label 'circles', followed by a plus button. The final row is a long 'reset' button. This component is surrounded by a red dashed-square labeled '<shape-dials>'. The next section to the right of the shape-dials section is labeled by a red label that says '<shape-count>'. It is a black box with 3 rows of text. The first row is '2 circles'. The second row is '2 squares', and the third row is '4 total'. Below the shape-dials and shape-count components is a long rectangle labeled '<shape-list>'. This is a long, horizonal, black rectangle and inside of it is a red square followed by a blue circle, blue circle, and another red square.

shape-count should be a component that only subscribes and reads from the store. Let us create a custom element and render the table:

Next we want to render actual counts rather than just 0. In this case we will need all values from the state:

  • state.circles
  • state.squares
  • state.shapeList

To achieve this, we will need a broad Redux selector that selects the entire state:

The counts are now pulled from the state in Redux and any updates to the state will update the component!

To accomplish this, we initialized the SelectorController with the shared Redux store and rendered the entire state.

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> elements with classes set to the shape name. Our pre-provided CSS styles will render the squares and circles based on the class name.

Next, let’s initialize our SelectorController to hook into Redux and render the shapeList:

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.

We should now have the decrement and reset buttons disabled upon invalid input, and a fully functional Lit-Redux application!

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.