Making Web Component properties behave closer to the platform

— updated on

Built-in HTML elements' properties all share similar behaviors, that don't come for free when you write your own custom elements. Let's see what those behaviors are, why you'd want to implement them in your web components, and how to do it, including how some web component libraries actually don't allow you to mimic those behaviors.

Built-in elements' behaviors

I said it already: built-in elements' properties all share similar behaviors, but there are actually several different such shared behaviors. First, there are properties (known as IDL attributes in the HTML specification) that reflect attributes (also known as content attributes); then there are other properties that are unrelated to attributes. One thing you won't find in built-in elements are properties whose value will change if an attribute change, but that won't update the attribute value when they are changed themselves (in case you immediately thought of value or checked as counter-examples, the situation is actually a bit more complex: those attributes are reflected by the defaultValue and defaultChecked properties respectively, and the value and checked properties are based on an internal state and behave differently depending on whether the user already interacted with the element or not).

Type coercion

But I'll start with another aspect that is shared by all of them, whether reflected or not: typing. DOM interfaces are defined using WebIDL, that has types and extended annotations, and defines mapping of those to JavaScript. Types in JavaScript are rather limited: null, undefined, booleans, IEEE-754 floating-point numbers, big integers, strings, symbols, and objects (including errors, functions, promises, arrays, and typed arrays). WebIDL on the other hand defines, among others, 13 different numeric types (9 integer types and 4 floating point ones) that can be further annotated to change their overflowing behavior, and several string types (including enumerations).

The way those types are experienced by developers is that getting the property will always return a value of the defined type (that's easy, the element owns the value), and setting it (if not read-only) will coerce the assigned value to the defined type. So if you want your custom element to feel like a built-in one, you'll have to define a setter to coerce the value to some specific type. The underlying question is what should happen if someone assigns a value of an unexpected type or outside the expected value space?

You probably don't want to use the exact WebIDL coercion rules though, but similar, approximated, rules that will behave the same most of the time and only diverge on some edge cases. The reason is that WebIDL is really weird: for instance, by default, numeric values overflow by wrapping around, so assigning 130 to a byte (whose value space ranges from -128 to 127) will coerce it to… -126! (128 wraps to -128, 129 to -127, and 130 to -126; and by the way 256 wraps to 0; for the curious, BigInt.asIntN and BigInt.asUintN will do such wrapping in JS, but you'll have to convert numbers to BigInt and back); non-integer values assigned to integer types are truncated by default, except when the type is annotated with [Clamp], in which case they're rounded, with half-way values rounded towards even values (something that only happens natively in JS when setting such non-integer values to typed arrays: Math.round(2.5) is 3, but Int8Array.of(2.5)[0] is 2).

Overall, I feel like, as far as primitive/simple types are concerned, boolean, integers, double (not float), string (WebIDL's DOMString), and enumerations are all that's needed; truncating (or rounding, but with JavaScript rules), and clamping or enforcing ranges for integers. In other words, wrapping integers around is just weird, and what matters is coercing to the appropriate type and value space. Regarding enumerations, they're probably best handled by the reflection rules though (see below), and treated only as strings: no single built-in element has a property of a type that's a WebIDL enum.

Reflected properties

Now let's get back to reflected properties: most properties of built-in elements reflect attributes or similarly (but with specific rules) correspond to an attribute and change its value when set; non-reflected properties are those that either expose some internal state (e.g. the current value or validation state of a form field), computed value (from the DOM, such as the selectedIndex of a select, or the cellIndex of a table cell) or direct access to DOM elements (elements of a form, rows of a table, etc.), or that access other reflected properties with a transformed value (such as the valueAsDate and valueAsNumber of input). So if you want your custom element to feel like a built-in one, you'll want to use similar reflection wherever appropriate.

The way reflection is defined is that the source of truth is the attribute value: getting the property will actually parse the attribute value, and setting the property will stringify the value into the attribute. Note that this means possibly setting the attribute to an invalid value that will be corrected by the getter. An example of this is setting the type property of an input element to an unknown value: it will be reflected in the attribute as-is, but the getter will correct it text. Another example where this is required behavior is with dependent attributes like those of progress or meter elements: without this you'd have to be very careful setting properties in the right order to avoid invalid combinations and having your set value immediately rewritten, but this behavior makes it possible to update properties in any order as the interaction between them are resolved internally and exposed by the getters: you can for example set the value to a value upper than max (on getting, value would be normalized to its default value) and then update the max (on getting, value could now return the value you previously set, because it wasn't actually rewritten on setting). Actually, these are not technically reflected then as they have specific rules, but at least they're consistent with actual reflected properties; for the purpose of this article, I'll consider them as reflected properties though.

This is at least how it theoretically works; in practice, the parsed value can be cached to avoid parsing every time the property is read; but note that there can be several properties reflecting the same attribute (the most known one probably being className and classList both reflecting the class attribute). Reflected properties can also have additional options, depending on their type, that will change the behavior of the getter and setter, not unlike WebIDL extended attributes.

Also note that HTML only defines reflection for a limited set of types (if looking only at primitive/simple types, only non-nullable and nullable strings and enumerations, long, unsigned long, and double are covered, and none of the narrower integer types, big integers, or the unrestricted double that allows NaN and infinity).

You can see how Mozilla tests the compliance of their built-in elements in the Gecko repository (the ok and is assertions are defined in their SimpleTest testing framework). And here's the Web Platform Tests' reflection harness, with data for each built-in element in sibling files, that almost every browser pass.

Events

Most direct changes to properties and attributes don't fire events: user actions or method calls will both update a property and fire an event, but changing a property programmatically generally won't fire any event. There are a few exceptions though: the events of type ToggleEvent fired by changes to the popover attribute or the open attribute of details elements, or the select event when changing the selectionStart, selectionEnd or selectionDirection properties of input and textarea elements (if you know of others, let me know); but notably changing the value of a form element programmatically won't fire a change or input event. So if you want your custom element to feel like a built-in one, don't fire events from your property setters or other attribute changed callbacks, but fire an event when (just after) you programmatically change them.

Why you'd want to implement those

If you're you (your team, your company) are the only users of the web components (e.g. building an application out of web components, or an internal library of reusable components), then OK, don't use reflection if you don't need it, you'll be the only user anyway so nobody will complain. If you're publicly sharing those components, then my opinion is that, following the principle of least astonishment, you should aim at behaving more like built-in elements, and reflect attributes.

Similarly, for type coercions, if you're the only users of the web components, it's ok to only rely on TypeScript (or Flow or whichever type-checker) to make sure you always pass values of the appropriate type to your properties (and methods), but if you share them publicly then you should in my opinion coerce or validate inputs, in which case you'd want to follow the principe of least astonishment as well, and thus use rules similar to WebIDL and reflection behaviors. This is particularly true for a library that can be used without specific tooling, which is generally the case for custom elements.

For example, all the following design systems can be used without tooling (some of them provide ready-to-use bundles, others can be used through import maps): Google's Material Web, Microsoft's Fluent UI, IBM's Carbon, Adobe's Spectrum, Nordhealth's Nord, Shoelace, etc.

How to implement them

Now that we've seen what we'd want to implement, and why we'd want to implement it, let's see how to do it. First without, and then with libraries.

I started collecting implementations that strictly follow (as an exercise, not as a goal) the above rules in a GitHub repository (strictly because it directly reuses the above-mentioned Gecko and Web Platform Tests harnesses).

Vanilla implementation

In a vanilla custom element, things are rather straightforward:

class MyElement extends HTMLElement {
  get reflected() {
    const strVal = this.getAttribute("reflected");
    return parseValue(strVal);
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.setAttribute("reflected", stringifyValue(newValue));
  }
}

or with intermediate caching (note that the setter is identical, setting the attribute will trigger the attributeChangedCallack which will close the loop):

class MyElement extends HTMLElement {
  #reflected;

  get reflected() {
    return this.#reflected;
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.setAttribute("reflected", stringifyValue(newValue));
  }

  static get observedAttributes() {
    return [ "reflected" ];
  }
  attributeChangedCallback(name, oldValue, newValue) {
    // Note: in this case, we know it can only be the attribute named "reflected"
    this.#reflected = parseValue(newValue);
  }
}

And for a non-reflected property (here, a read-write property representing an internal state):

class MyElement extends HTMLElement {
  #nonReflected;
  get nonReflected() {
    return this.#nonReflected;
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.#nonReflected = newValue;
  }
}

Because many rules are common to many attributes (the coerceType operation is defined by WebIDL, or using similar rules, and the HTML specification defines a handful of microsyntaxes for the parseValue and stringifyValue operations), those could be packaged up in a helper library. And with decorators coming to ECMAScript (and already available in TypeScript), those could be greatly simplified:

class MyElement extends HTMLElement {
  @reflectInt accessor reflected;
  @int accessor nonReflected;
}

I actually built such a library, mostly as an exercise (and I already learned a lot, most of the above details actually). It's currently not published on NPM but you can find it on Github

With a library

Surprisingly, web component libraries don't really help us here.

First, like many libraries nowadays, most expect people to just pass values of the appropriate types (relying on type checking through TypeScript) and basically leave you handling everything including how to behave in the presence of unexpected values. While it's OK, as we've seen above, in a range of situations, there are limits to this approach and it's unfortunate that they don't provide tools to make it easier at least coercing types.

Regarding reflected properties, most libraries tend to discourage you from doing it, while (fortunately!) supporting it, if only minimally.

All libraries (that I've looked at) support observed attributes though (changing the attribute value updates the property, but not the other way around), and most default to this behavior.

Now let's dive into the how-to with Lit, FAST, and then Stencil (other libraries left as a so-called exercise for the reader).

With Lit

By default, Lit reactive properties (annotated with @property()) observe the attribute of the same (or configured) name, using a converter to parse the value if needed (by default only handling numbers through a plain JavaScript number coercion, booleans, strings, or possibly objects or arrays through JSON.parse(); but a custom converter can be given). If your property is not associated to any attribute (but needs to be reactive to trigger a render when changed), then you can annotate it with @property({ attribute: false }) or @state() (the latter is meant for internal state though, i.e. private properties).

To make a reactive property reflect an attribute, you'll add reflect: true to the @property() options, and Lit will use the converter to stringify the value too. This won't be done immediately though, but only as part of Lit's reactive update cycle. This timing is a slight deviation compared to built-in elements that's probably acceptable, but it makes it harder to implement some reflection rules (those that set the attribute to a different value than the one returned by the getter) as the converter will always be called with the property value (returned by the getter, so after normalization). For a component similar to progress or meter with dependent properties, Lit recommends correcting the values in a willUpdate callback (this is where you'd check whether the value is valid with respect to the max for instance, and possibly overwrite its value to bring it in-range); this means that attributes will have the corrected value, and this requires users to update all properties in the same event loop (which will most likely be the case anyway).

It should be noted that, surprisingly, Lit actively discourages reflecting attributes:

Attributes should generally be considered input to the element from its owner, rather than under control of the element itself, so reflecting properties to attributes should be done sparingly. It's necessary today for cases like styling and accessibility, but this is likely to change as the platform adds features like the :state pseudo selector and the Accessibility Object Model, which fill these gaps.

No need to say I disagree.

For type coercion and validation, Lit allows you to have your own accessors (and version 3 makes it even easier), so everything's ok here, particularly for non-reflected properties:

class MyElement extends LitElement {
  #nonReflected;
  get nonReflected() {
    return this.#nonReflected;
  }
  @state()
  set nonReflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.#nonReflected = newValue;
  }
}

For those cases where you'd want the attribute to possibly have an invalid value (to be corrected by the property getter), it would mean using a non-reactive property wrapping a private reactive property (this assumes Lit won't flag them as errors in future versions), and parsing the value in its getter:

class MyElement extends LitElement {
  @property({ attribute: "reflected", reflect: true })
  accessor #reflected = "";

  get reflected() {
    return parseValue(this.#reflected);
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.#reflected = stringifyValue(newValue);
  }
}

or with intermediate caching (note that the setter is identical):

class MyElement extends LitElement {
  @property({ attribute: "reflected", reflect: true })
  accessor #reflected = "";

  #parsedReflected = "";
  get reflected() {
    return this.#parsedReflected;
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.#reflected = stringifyValue(newValue);
  }

  willUpdate(changedProperties) {
    if (changedProperties.has("#reflected")) {
      this.#parsedReflected = parseValue(this.#reflected);
    }
  }
}

It might actually be easier to directly set the attribute from the setter (and as a bonus behaving closer to built-in elements) and only rely on an observed property from Lit's point of view (setting the attribute will trigger attributeChangedCallback and thus Lit's observation code that will use the converter and then set the property):

class MyElement extends LitElement {
  @property({
    attribute: "reflected",
    converter: (value) => parseValue(value),
  })
  accessor #reflected = "";

  get reflected() {
    return this.#reflected;
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.setAttribute("reflected", stringifyValue(newValue));
  }
}

Note that this is actually very similar to the approach in the vanilla implementation above but using Lit's own lifecycle hooks. It should also be noted that for a USVString that contains a URL (where the attribute value is resolved to a URL relative to the document base URI) the value needs to be processed in the getter (as it depends on an external state –the document base URI– that could change independently from the element).

A previous version of this article contained a different implementation that happened to be broken.
class MyElement extends LitElement {
  #reflected = "";
  get reflected() {
    return this.#reflected;
  }
  @property()
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    const stringValue = stringifyValue(newValue);
    // XXX: there might be a more optimized way
    // than stringifying and then parsing
    this.#reflected = parseValue(stringValue);
    // Avoid unnecessarily triggering attributeChangedCallback
    // that would reenter that setter.
    if (this.getAttribute("reflected") !== stringValue) {
      this.setAttribute("reflected", stringValue);
    }
  }
}

This implementation would for instance have the setter called with null when the attribute is removed, which actually needs to behave differently than user code calling the setter with null: in the former case the property should revert to its default value, in the latter case that null would be coerced to the string "null" or the numeric value 0 and the attribute would be added back with that value.

If we're OK only reflecting valid values to attributes, then we can fully use converters but things aren't necessarily simpler (we still need the custom setter for type coercion and validation, and marking the internal property as reactive to avoid triggering the custom setter when the attribute changes; we don't directly deal with the attribute but we now have to normalize the value in the setter in the same way as stringifying it to the attribute and parsing it back, to have the getter return the appropriate value):

const customConverter = {
  fromAttribute(value) {
    return parseValue(value);
  },
  toAttribute(value) {
    return stringifyValue(value);
  },
};

class MyElement extends LitElement {
  @property({ reflect: true, converter: customConverter })
  accessor #reflected = "";
  get reflected() {
    return this.#reflected;
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    // XXX: this should use a more optimized conversion/validation
    this.#reflected = parseValue(stringifyValue(newValue));
  }
}

With FAST

I know FAST is not used that much but I wanted to cover it as it seems to be the only library that reflects attributes by default. By default it won't do any type coercion unless you use the mode: "boolean", which works almost like an HTML boolean attribute, except an attribute present but with the value "false" will coerce to a property value of false!

Otherwise, it works more or less like Lit, with one big difference: the converter's fromView is also called when setting the property (this means that fromView receives any external value, not just string values from the attribute). But unfortunately this doesn't really help us as most coercion rules need to throw at one point and we want to do it only in the property setters, never when parsing attribute values; and those rules that don't throw will have possibly different values between the attribute and the property getter (push invalid value to the attribute, sanitize it on the property getter), or just behave differently between the property (e.g. turning a null into 0 or "null") and the attribute (where null means the attribute is not set, and the property should then have its default value which could be different from 0, and will likely be different from "null").

This means that in the end the solutions are almost identical to the Lit ones (here using TypeScript's legacy decorators though; and applying the annotation on the private property to avoid triggering the custom setter on attribute change):

class MyElement extends FASTElement {
  @attr({ attribute: "reflected" })
  private _reflected = "";

  get reflected() {
    return parseValue(this._reflected);
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this._reflected = stringifyValue(newValue);
  }
}

or with intermediate caching (note that the setter is identical):

class MyElement extends FASTElement {
  @attr({ attribute: "reflected" })
  private _reflected = "";

  private _reflectedChanged(oldValue, newValue) {
    this._parsedReflected = parseValue(newValue);
  }

  private _parsedReflected;
  get reflected() {
    return this._parsedReflected;
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.__reflected = stringifyValue(newValue);
  }
}

Or if you want immediate reflection to the attribute (the internal property can now be used to store the parsed value):

class MyElement extends FASTElement {
  @attr({
    attribute: "reflected",
    mode: "fromView",
    converter: {
      fromView(value) {
        return parseValue(value);
      },
      toView(value) {
        // mandatory in the converter type
        throw new Error("should never be called");
      }
    }
  })
  private _reflected;

  get reflected() {
    return this._reflected ?? "";
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this.setAttribute("reflected", stringifyValue(newValue));
  }
}

Note that the internal property is not initialized, to avoid calling the converter's fromView, and handled in the getter instead (our fromView expects a string or null coming from the attribute, so we'd have to initialize the property with such a string value which would hurt readability of the code as that could be a value different from the one actually stored in the property and returned by the pblic property getter).

If we're OK only reflecting valid values to attributes, then we can fully use converters but things aren't necessarily simpler (we still need the custom setter for type coercion and validation, and marking the internal property as reactive to avoid triggering the custom setter when the attribute changes; we don't directly deal with the attribute but we still need to call stringifyValue as we know the converter's fromView will receive the new value):

const customConverter = {
  fromView(value) {
    return parseValue(value);
  },
  toView(value) {
    return stringifyValue(value);
  },
};

class MyElement extends FASTElement {
  @attr({ attribute: "reflected ", converter: customConverter })
  private _reflected;

  get reflected() {
    return this._reflected ?? "";
  }
  set reflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this._reflected = stringifyValue(newValue);
  }
}

For non-reflected properties, you'd want to use @observable instead of @attr, except that it doesn't work on custom accessors, so you'd have to do it manually:

class MyElement extends FASTElement {
  private _nonReflected = "";
  get nonReflected() {
    Observable.track(this, 'nonReflected');
    return this._nonReflected;
  }
  set nonReflected(value) {
    const newValue = coerceType(value);
    // …there might be additional validations here…
    this._nonReflected = newValue;
    Observable.notify(this, 'nonReflected');
  }
}

With Stencil

First a disclosure: I never actually used Stencil, only played with it a bit locally in a hello-world project while writing this post.

Stencil is kind of special. It supports observable attributes through the @Prop() decorator, and reflected ones through @Prop({ reflect: true }). It will however reflect default values to attributes when the component initializes, doesn't support custom converters, and like FAST will convert an attribute value of "false" to a boolean false. You also have to add mutable: true to the @Prop() if the component modifies its value (Stencil assumes properties and attributes are inputs to the component, not state of the component).

A @Prop() must be public too, and cannot have custom accessors. You can use a @Watch() method to do some validation, but throwing from there won't prevent the property value from being updated; you can revert the property to the old value from the watch method, but other watch methods for the same property will then be called twice, and not necessarily in the correct order (depending on declaration order).

You cannot expose properties on the element's API if they are not annotated with @Prop(), making them at a minimum observe an attribute.

In other words, a Stencil component cannot, by design, feel like a built-in custom element (another thing specific to Stencil: besides @Prop() properties, you can expose methods through @Method but they must be async).

Discuss: Dev.to