Sam Thorogood

Disposable Web Components

Web Components, or many component systems, are often described as "re-usable". That actually has two meanings.

  1. You can construct the same <foo-bar> or FooBar component many places throughout your site; and
  2. An individual instance of <foo-bar> or FooBar can be re-used multiple times.

I want to talk about the second point. When you change properties or HTML attributes of a Web Component, or update props in React, you'll cause an update and possibly a re-render.

That's fine, and perhaps convenient—think of a button with a label, or a disabled property. Changing that disabled property from outside that element will cause it to re-render and potentially update some internal state in order to achieve its goal.

However, as an author, I now need to actually transition my element from one state to another. What if I didn't want to—what if I'd rather start again? 🤔

An Example

Cloud Firestore is a database by Google. Its most compelling feature is that it provides real-time updates to your data. To do this, we have to set up a subscriber and be able to clear that subscriber as part of a cleanup process. (For context, check out the first example on this page.)

So, in a traditional component model, I need to care about subscribing, unsubscribing and potentially resubscribing at several points:

This might look like:

class ChatElement extends LitElement {
  static properties = { chat: { type: String } };

  constructor() {
    super();
    // constructed but not yet on the page
  }

  connectedCallback() {
    super.connectedCallback();
    if (this.chat) {
      this.unsub = onSnapshot(doc(db, 'chats', this.chat), this.#update);
    }
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    this?.unsub();
  }

  willChange(properties) {
    if (properties.has('chat')) {
      this?.unsub();
      if (this.chat) {
        this.unsub = onSnapshot(doc(db, 'chats', this.chat), this.#update);
      }
    }
  }

  #update = (snapshot) => {
    // do something with database result
  };

  render() {
    return html`<!-- TODO: render something -->`;
  }
}
customElements.define('chat-view', ChatElement);

We have a lot of callbacks to represent the lifecycle of this element. This approach is—to be fair—pretty normal for Web Components. But class components in React have similer callbacks.

Diagram showing lifecycle of a Web Component
Our Web Component has a bunch of complex lifecycle

And fundamentally we only have a class which lets users arbitrarily set values, like the chat property. You cannot consider it fixed in the constructor. We can't even do practical setup work in the constructor, because there's no matching "destructor" lifecycle method in JS^.

^yes I know about FinalizationRegistry but to quote that page… "it's best avoided if possible".

The Solution

We can extend a component system to have clearer lifecycle methods. 💡

Diagram showing lifecycle of a 'Disposable Web Component'
We can simplify the lifecycle for a DisposableElement

I've built a class called DisposableElement, although you don't get to create it directly—you can subclass an inner part, called DisposableInner. This inner class will enable two features:

  1. It'll be disposed and recreated when properties—of our choice—change
  2. It'll only be instantiated when it's attached to the page, between connectedCallback and disconnectedCallback

Our Firestore example can now be simplified:

class ChatInner extends DisposableInner {
  static properties = { chat: { type: String } };

  constructor(cleanup) {
    super();

    // If we have a valid chat, subscribe to its data, and cleanup the subscription when done.
    if (this.chat) {
      const unsub = onSnapshot(doc(db, 'chats', this.chat), this.#update);
      cleanup(unsub);
    }
  }

  shouldDispose(properties) {
    // If 'chat' changes, recreate this ChatInner.
    return properties.has('chat');
  }

  #update = (snapshot) => {
    // do something with database result
  };

  render() {
    return html`<!-- TODO: render something -->`;
  }
}

const ChatElement = disposableElement(ChatInner, { chat: '' });
customElements.define('chat-view', ChatElement);

Those features turn into pretty good invariants. We know that while this ChatInner is alive—between its constructor and cleanup—that it's on the page ✅, and its chat property has not changed ✅.

It's not a total reduction in code. There's still handlers and cleanup to be done, but I think this is a much easier experience for an element that has props—in this case, the chat we want—that is core to the element's very existence. 💬

It's Lit 🔥

This approach is based on Google's lightweight Web Components library, lit, and I think doesn't have a generic solution—it needs to be built on top of whatever library you're working with. Because it uses lit, rendering is actually still efficient—the inner has a render() method just like you're used to. And updates—even across new ...Inner instances—will reuse exactly the same DOM.

I'd like you to check out a demo.

The library is available in the usual place. It has a peer dependency on lit, in an attempt not to add yet more complex graph edges into the graph of your project. Honestly, though? I'd like your feedback and thoughts, but if this idiom is useful to you, consider building it in to your project directly. I think it's useful for mine. 🙇

Alternatives

If all you have is vanilla components, one idea is that you could accept an initialChat property with a contract saying—an element won't accept changes to this property You must instead recreate the element. However, this is kind of unidiomatic—I can no longer really use element directly in data-binding, because binding to an initial property where changes aren't respected… well, it just doesn't make sense.

React

In React, there's the key prop, which is magic 🔮 and is used to uniquely identify elements (examples typically show it to efficiently reorder a list). If you change it, React will always throw away the previous component and re-render it. In this way, it makes the component—functional or class-based—a type of 'disposable component'.

However, it doesn't seem to be advertised this way (again, it's just shown for lists, although you could use it anywhere), and it doesn't make the lifecycle clearer through static improvements to your code—the key is still passed in props and it's only by convention that it won't change over time. 🙅

React's useEffect hook is another related concept: it's often for subscribing and unsubscribing to some external data when a prop, or a set of props, change. Great! This is simpler than the first example of Web Components above (obviously, since it's a low-level API) but it's also simpler than what lit provides out of the box^. You can also use this with key, which means that—yes, it's only by convention—but the effect callbacks for this instance will only ever be called once to setup and once to teardown.

^lit has a few new tricks up its sleeve but that's out of scope of this post.

An aside on lit's repeat

lit has a helpful directive called repeat. It serves a similar purpose to React's key, but it's not really part of any contract. The element that's rendering a list uses it to more efficiently arrange its children. It can be used to support forced dispose/recreate of arbitrary elements—you can always render a list of length one—but it's awkward and the child element you're rendering can't be sure it's only going to be used inside a repeat.

Finished

If this Web Components talk is a bit confusing, and you come from a React world—that's fine, just spend 27 minutes watching me explain them. There's also a tl;dr: Web Components work just like real HTML elements and can go anywhere on your page. That part is pretty important—a lot of this work is motivated by the idea that Web Components don't nessecarily have some clear layout, including the adage of pushing state down and events up. They're a tool that you can hold how you like (although the video has some tips). 📽️🛠️

I believe that Disposable Web Components are definitely one way to hold Web Components that are going to simplify a whole bunch of property (or attributes, since these are real HTML elements) management for your components.

Hit me up on Twitter and let me know how fast you're intergrating Disposable Web Components into your framework. Or why it's terrible. Who knows! 📢🐦