this space intentionally left blank

November 28, 2023

Filed under: tech»web»components

goodbytes: Designing Custom Element Base Classes

In my mind, Michael Crichton's Jurassic Park marks the last time object-oriented programming was cool. Dennis Nedry, the titular park's sole computer engineer, adds a backdoor to the system disguised as "a block of code that could be moved around and used, the way you might move a chair in a room." Running whte_rabt.obj as a shell script turns off the security systems and electric fences, kicking off the major crisis that drives the novel forward. Per usual for Crichton, this is not strictly accurate, but it is entertaining.

(Crichton produced reactionary hack work — see: Rising Sun, Disclosure, and State of Fear — roughly as often as he did classic high-tech potboilers, but my favorite petty grudge is in The Lost World, the cash-grab sequel to Jurassic Park, which takes a clear potshot at the "This is a UNIX system, I know this!" scene from Spielberg's film: under siege by dinosaurs, a young woman frantically tries to reboot the security system before suddenly realizing that the 3D graphics onscreen would require a high-bandwidth connection, implying — for some reason — a person-sized maintenance tunnel she can use as an escape route. I love that they can clone dinosaurs, but Jurassic Park engineers do not seem to have heard of electrical conduits.)

In the current front-end culture, class-based objects are not cool. React is (ostensibly) functional wherever possible, and Svelte and Vue treat the module as the primary organizational boundary. In contrast, web components are very much built on the browser platform, and browsers are object-oriented programs. You just can't write vanilla JavaScript without using new, and I've always wondered if this, as much as anything else, is the reason a lot of framework authors seem to view custom elements with such disdain.

Last week, I wrote about slots and shadow DOM as a way to build abstract domain-specific languages and expressive web components. In this post, I want to talk about how base classes and inheritance can smooth out its rough edges, and help organize and arrange the shape of your application. Call me a dinosaur (ha!), but I think they're pretty neat.

Dino DNA

Criticisms of custom elements often center around the amount of code that it takes to write something fairly simple: comparing the 20-line boilerplate of a completely fresh web component against, say, a function with some JSX in it. For some reasons, these comparisons never discuss how that JSX is transpiled and consumed by thousands of lines of framework dependencies — that's just taken for granted — or that some equivalent could also exist for custom elements.

That equivalent is your base class. Rather than inheriting directly from HTMLElement, you inherit from a middleware class that extends it, and fills in the gaps that the browser doesn't directly provide. Almost every project I work on either starts with a base element, or eventually acquires one. Typically, you'll want to include:

  • Some kind of templating for the shadow DOM, and optionally for the light DOM.
  • Code that reflects observed attributes to properties, or vice versa.
  • Method binding, for event listeners and callbacks.
  • Event dispatching, using either CustomEvent or a subclass for your application.

If you don't feel capable of providing these things, or you're worried about the maintenance burden, you can always use someone else's. Web component libraries like Lit or Stencil basically provide a starter class for you to extend, already packed with things like reactive state and templating. Especially if you're working on a really big project, that might make sense.

But writing your own base class is educational at the very least, and often easier than you might think, especially if you're not working at big corporate scale. In most of my projects, it's about 50 lines (which I often copy verbatim from the last project), and you can see an example in my guidebook. The templating is the largest part, and the part where just importing a library makes the most sense, especially if you're doing any kind of iteration. That said, if you're mostly manipulating individual, discrete elements, a pattern I particularly like is:

class TemplatedElement extends HTMLElement {
  elements = {};

  constructor() {
    super();
    // get the shadow root
    // in other methods, we can use this.shadowRoot
    var root = this.attachShadow({ mode: "open" });
    // get the template from a static class property
    var { template } = new.target;
    if (template) {
      root.innerHTML = template;
      // store references to marked template elements
      for (var element of root.querySelectorAll("[as]")) {
        var name = element.getAttribute("as");
        this.#elements[name] = element;
      }
    }
  }
}

From here, a class extending TemplatedElement can set a string as the static template property, which will then be used to set up the shadow DOM on instantiation. Any tag in that template with an "as" attribute will be stored on the elements lookup object, where we can then add event listeners or change its content:

class CounterElement extends TemplatedElement {
  static template = `
<div as="counter">0</div>
<button as="increment">Click me!</button>
  `;
  
  #count = 0;
  
  constructor() {
    // run the base class constructor
    super();
    // get our cached shadow elements
    var { increment, counter } = this.elements;
    increment.addEventListener("click", () => {
      counter.innerHTML = this.#count++;
    });
  }
}

It's simple, but it works pretty well, especially for the kinds of less-intrusive use cases that we're seeing in the new wave of HTML components.

For the other base class responsibilities, a good tip is to try to follow the same API patterns that are used in the platform, and more specifically in JavaScript in general (a valuable reference here is the Web Platform Design Principles). For example, when providing method binding and property reflection, I will often build the interface for these as arrays assigned to static properties, because that's the pattern already being used for observedAttributes:

class CustomElement extends BaseClass {
  static observedAttributes = ["src", "controls"];
  static boundMethods = ["handleClick", "handleUpdate"];
  static reflectedAttributes = ["src"];
}

I suspect that once decorators are standardized, they'll be a more pleasant way to handle some of this boilerplate, especially since a lot of the web component frameworks are already doing so via Typescript. But if you're using custom elements, there's a reasonable chance that you're interested in no-build (or minimal build) systems, and thus may want to avoid features that currently require a transpiler.

Clever Girl

If you are building web components entirely as leaf nodes that are meant to be inserted into an arbitrary page, or embedded into another framework, mimicking the platform is probably enough. For example, on an input-related element you might add a getter to your class that provides the valueAsNumber property just like the browser's own input tags.

But if you're designing larger applications, then your components will need to interact with each other. And in that case, a class is not just a way of isolating some DOM code, it's also a contract between application modules for how they manage state and communication. This is not new or novel — it's the foundation of model-view-controller UI dating back to Smalltalk — but if you've learned web development in the era since Backbone fell out of popularity, you may have never really had to think about state and interaction between components, as opposed to UI functions that all access slices of a common state store (or worse, call out to hooks and magically get served state from the aether).

Here's an example of what I mean: the base class for drawing instructions in Tarot, Chalkbeat's social media image generator, does the normal templating/binding dance in its constructor. It also has some utility methods that most canvas operations will need, such as converting between normalized coordinates and pixels or turning variable-length CSS padding strings into a four-item array. Finally, it defines a number of "stub" methods that subclasses are expected to override:

  • persist() and restore() transfer values between elements with the same ID when the user switches card layouts, triggered by the connected and disconnected callbacks.
  • getLayout() returns a DOMRect with the bounding box that the component plans to render to, so that parent elements can perform layout tasks like flex spacing.
  • draw() actually renders to a canvas context, usually based on the information that getLayout() provided.

When Tarot needs to re-render the canvas, it starts at the top level of the input form, loops through each direct child, and calls draw(). Some instructions, like images or rectangle fills, render immediately and exit. The layout brushes, <vertical-spacer> and <vertical-stack>, first call getLayout() on each of their children, and use those measurements to apply a transform to the canvas context before they ask each child to draw. Putting these methods onto the base class in Tarot makes the process of adding a new drawing type clear and explicit, in a way that (for me) the "grab bag of props" interface in React does not.

Two brushes actually take this a little further. The <series-logo> and <logo-brush> elements don't inherit directly from the Brush base class, but from a specialized subclass of it with properties and methods for storing and tinting bitmaps. As a result, they can take a single-color input PNG and alter its pixels to match any of the theme colors selected while preserving alpha, which means we can add new brand colors to the app and not have to generate all new logo art.

Planning the class as an API contract means that when they're slotted or placed, we can use duck-typing in our higher-level code to determine whether elements should participate in a given operation, by checking whether they have a method name that matches our condition. We can also use instanceof to check if they have the required base class in their prototype chain, which is more strict.

Hold Onto Your Butts

It's worth noting that this approach has its detractors, and has for a (relatively) long time. In 2015, the React team published a blog post claiming that traditional object-oriented code inherently creates tight coupling, and the code required grows "as the square of the number of possible states of the component." Personally I find this disingenuous, especially when you step back and think about the scale of the infrastructure that goes into the "easier" rendering method it describes. With a few small changes, it'd be indistinguishable from the posts that have been written discounting custom elements themselves, so I guess at least they're consistent.

As someone who cut their teeth working in ActionScript 3, it has never been obvious to me that stateful objects are a bad foundation for creating rich interfaces, especially when we look at the long history of animation libraries for React — eventually, every pure functional GUI seems to acquire a bunch of pesky escape hatches in order to do anything useful. Weird how that happens! My hot take is that humans are messy, and so code that interacts directly with humans tends to also be a little messy, and trying to shove it into an abstract conceptual model is likely to fail in frustrating ways. Objects are often untidy, but they give us more slack, and they're easier to map to a mental model of DOM and state relationships.

That said, you can certainly create bad class code, as the jokes about AbstractFactoryFactoryAdapter show. I don't claim to be an expert on designing inheritance — I've never even drawn a UML diagram (one person in the audience chuckles, glances around, immediately quiets). But there are a few basic guidelines that I've found useful so far.

Remember that state is inspectable. If you select a tag in the dev tools and then type $0.something in the console, you can examine a JS value on that element. You can also use console.dir($0) to browse through the entire thing, although this list tends to be overwhelming. In Chrome, the dev tools can even examine private fields. This is great for debugging: I personally love being able to see the values in my application via its UI tree, instead of needing to set breakpoints or log statements in pure rendering functions.

Class instances are great places for related platform objects. When you're building custom elements, a big part of the appeal is that they give you automatic lifecycle hooks for the section of the page tree that they wrap. So this might be obvious, but use your class to cache references to things like Mutation Observers or drawing contexts that are related to the DOM subtree, even if they aren't technically its state, and use the lifecycle to set them up and tear them down.

Use classes to store local state, not application state. In a future post, I want to write about how to create vanilla code that can fill the roles of stores, hooks, and other framework utilities. The general idea, however, is that you shouldn't be using web components for your top-level application architecture. You probably don't need <application-container> or <database-connection>. That's why you...

Don't just write classes for your elements. In my podcast client, a lot of the UI is driven by shared state that I keep in IndexedDB, which is notoriously frustrating to use. Rather than try to access this through a custom element, there's a Table class that wraps the database and provides subscription and manipulation/iteration methods. The components in the page use instances of Table to get access to shared storage, and receive notification events when something else has updated it: for example, when the user adds a feed from the application menu, the listing component sees that the database has changed and re-renders to add that podcast to the list.

Be careful with property/method masking. This is far more relevant when working with other people than if you're writing software for yourself, but remember that properties or methods that you create in your class definitions will supplant any existing fields that exist on HTMLElement For example, on one project, I stored the default slot for a component on this.slot, not realizing that Element.slot already exists. Since no code on the page was checking that property, it didn't cause any problems. But if you're working with other people or libraries that expect to see the standard DOM value, you may not be so lucky.

Consider Symbols over private properties to avoid masking. One way to keep from accidentally overwriting a built-in field name is by using private properties, which are prefixed with a hash. However, these have some downsides: you can't see them in the inspector in Firefox, and you can't access them from subclasses or through Proxies (I've written a deeper dive on that here). If you want to store something on an element safely, it may be better to use a Symbol instead, and export that with your base class so that subclasses can access it.

export const CANVAS = Symbol("#canvas");
export const CONTEXT = Symbol("#context");

export class BitmapElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this[CANVAS] = document.createElement("canvas");
    this[CONTEXT] = this[CANVAS].getContext("2d");
  }
}

The syntax itself looks a little clunkier, but it offers encapsulation closer to the protected keyword in other languages (where subclasses can access the properties but external code can't), and I personally think it's a nice middle ground between actual private properties and "private by convention" naming practices like this._privateButNotReally.

Inherit broadly, not deeply. Here, once again, it's instructive to look at the browser itself: although there are some elements that have extremely lengthy prototype chains (such as the SVG elements, for historical reasons), most HTML classes inherit from a relatively shallow list. For most applications, you can probably get away with just one "framework" class that everything inherits from, sometimes with a second derived class for families of specific functionality (such as embedded DSLs).

There's a part of me that feels like jumping into a wave of interest in web components with a tribute to classical inheritance has real "how do you do, fellow kids?" energy. I get that this isn't the sexiest thing you can write about an API, and it's very JavaScript-heavy for people who are excited about the HTML component trend.

But it also seems clear to me, reading the last few years of commentary, that a lot of front-end folks just aren't familiar with this paradigm — possibly because frameworks (and React in particular) have worked so hard to isolate them from the browser itself. If you try to turn web components into React, you're going to have a bad time. Embrace the platform, learn its design patterns on their own terms, and while it still won't make object orientation cool, you'll find it's a much more pleasant (and stable) environment than it's been made out to be.

Past - Present