Lit elements with Xstate

Time to read: 12 minutes

This blog focuses on state management inside of web components.

If you are not familiar with Web Components, the Light DOM (normal DOM) and the Shadow DOM (the encapsulated DOM you can use with web components) then this is an excellent piece of background reading

The TL;DR is:

  • Web components are HTML Custom Elements that can be adopted by the DOM; they require Javascript to work, and can have their own observable properties.
  • The Light DOM is standard HTML elements which can be written in plain text.
  • The Shadow DOM is an encapsulated sub tree with it's own root that maintains it's own styles propagated down, but not back up to the light DOM it was rendered in.

State Management

Unless your are building a purely presentational design system you are likely dealing with state management inside the browser.

In order to make the client experience deterministic when the application grows it becomes good practice to fan all modifications into a central state and resolve the new state. This is common in frameworks like react (with context providers, or redux, zuustand etc.).

Lit has a proposed context implementation here which enables decoration of properties to consume their value from a central store. This will solve passing props to elements via prop drilling.

There is still a need to coordinate state changes within the context, and so I will show how xstate can be used to achieve this.

Xstate

xstate is a tool for state charts and state machines.

It makes it possible to build state machines and coordinate the dispatch of events which make modifications to the state.

Setup a machine in Lit

There will be three components to this:

  1. The machine, which is a state machine with two events to increment and decrement a counter.
  2. A DOM Event adapter for dispatching CustomEvents to an eventListener and sending those events to xstate
  3. A Lit Mediator Element which creates an interpreted xstate machine and listens for CustomEvents. The mediator will send events to the machine, which will invoke a callback to update the observed properties. These properties can be drilled down into child elements.

Lets sketch that out with a handy diagram:

Block diagram of elements with events and machine

01 - The machine

The below is an example of a state machine to increment and decrement a number. This tool is overkill for such a use case, but I want to use a simple example as not to create too much cognitive load for you to follow.

import { assign, createMachine } from "xstate";

export type Context = { count: number };
export type Events = { type: "INC" } | { type: "DEC" };

export const machine = createMachine(
  {
    predictableActionArguments: true,
    tsTypes: {} as import("./index.typegen").Typegen0,
    schema: {
      context: {} as Context,
      events: {} as Events,
    },
    initial: "active",
    context: {
      count: 0,
    },
    states: {
      active: {
        on: {
          INC: { actions: "increment" },
          DEC: { actions: "decrement" },
        },
      },
    },
  },
  {
    actions: {
      increment: assign({
        count: (context) => context.count + 1,
      }),
      decrement: assign({
        count: (context) => context.count - 1,
      }),
    },
  }
);

The state machine is a declaration of how modification can occur and in what direction. To use the machine we need to create an instance and send events to it.

02 - The DOM Event Adapter

We want to be able to use our state machine, by sending the events that it is listening for.

The idiomatic way to do this inside the DOM is to listen for CustomEvents - and execute an eventListener.

We want to constrain the published events to the set of allowed events that xstate machine is listening for.

So to do that we can create a factory function which returns new events, see below:

import type { Events } from "../machines";

export const DOMEventPublisher = (event: Events) => {
  /**
   * Create a new event that will bubble through
   * multiple shadow roots
   */
  return new CustomEvent<Events>("xstate-broadcast", {
    detail: event,
    composed: true,
    bubbles: true,
  });
};

// This will make TS intellisense handle these event names
declare global {
  interface HTMLElementEventMap {
    "xstate-broadcast": CustomEvent<Events>;
  }
}

We can use the factory now to create events that will:

  • Be constrained to our expected event types from the machine when writing typescript
  • Be bubbled through each node in the DOM so any listeners can take action
  • Be composed so they will propagate out of the Shadow DOM into the standard DOM.

This ensures we will be able to listen on these events wherever we start the xstate machine.

03 - A Lit Mediator Element

We want to be able to both consume CustomEvents, and update xstate state and context, then pass the updated state to all child elements. In order to do this we will use something called the Mediator Method.

Events up, props down - source Event communication between web components - Lit University (Advanced)

I will show what that looks like in practice to enable us to create a generic element that could be used with any xstate machine to subscribe to the set of expected events and pass back down the props to any child elements.

The mediator parent looks like below:

import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { interpret } from "xstate";

// Import our increment machine
import { machine } from "./machines";

@customElement("my-element")
export class MyElement extends LitElement {
  // Scope the service within the element class
  service = interpret(machine);

  // Set the state as a property to make it observable
  @property({ attribute: false })
  state = this.service.initialState.value;

  // Set the context as a property to make it observable
  @property({ attribute: false })
  context = this.service.initialState.context;

  firstUpdated() {
    // Listen for special events for xstate
    this.addEventListener("xstate-broadcast", (e) => {
      this.service.send(e.detail);
    });
  }

  // Start service on connection/adoption into the Light DOM
  connectedCallback(): void {
    super.connectedCallback();
    // Register a callback for all event transitions
    this.service.onTransition((state, _event) => {
      /**
       * Assign state and context REMEMBER if these
       * are objects you need to create a new object
       * for Lit to detect the hasChanged!!
       */
      this.state = state.value;
      this.context = state.context;
    });
    this.service.start();
  }

  // Stop service on removal from the Light DOM
  disconnectedCallback(): void {
    this.service.stop();
  }

  render() {
    html`<p>The current state is <em>${this.state}</em></p>`;
  }
}

This code uses some lifecycle methods from Lit to ensure that the machine will start and stop, when the element is adopted or removed from the DOM respectively.

It also ensures that when the element is first created the properties are assigned to the state machines default values; this is important for a deterministic first paint (reduce Cumulative Layout Shift).

Finally in the firstUpdated event, which fires only once, on the elements first update; we ensure that we establish a listener for all elements in the tree.

We could have an issue with race conditions if we rendered some elements that dispatch events before listener is in place.

We would also have issues if we rendered two of the my-element web components on the same page, because events from one would propagate to the other.

Summary of the pattern

Now we have a mediator setup, we are listening on custom xstate-broadcast events.

  • When we receive those events in the parent, we call into the local this.service to send the events to the interpreted machine.
  • When the machine receives those events it will execute a callback, which updates any of our observable properties that have changes.
  • Lit will run the hasChanged function for each property, and if they have changed it will perform an update.
  • Any elements below the Mediator will receive the updated state via prop drilling (for now, but later we could tie our callback into a Lit labs context and have all our other elements subscribe).

Wrap up

I have been on quite a journey with Lit to understand how state should be managed.

The resources at Lit university are the best to get started.

I'll be continuing this series on Lit and intend to cover topis like:

  • Real user monitoring inside the shadow DOM
  • Production grade internationalization
  • Design systems

And likely more, so if your interested you can subscribe to my rss feed