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:
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 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.
There will be three components to this:
Lets sketch that out with a handy diagram:
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.
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:
This ensures we will be able to listen on these events wherever we start the xstate machine.
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.
Now we have a mediator setup, we are listening on custom xstate-broadcast
events.
this.service
to send the events to the interpreted machine.hasChanged
function for each property, and if they have changed it will perform an update.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:
And likely more, so if your interested you can subscribe to my rss feed