DEV Community

Ron Newcomb
Ron Newcomb

Posted on • Edited on

The Many Ways of Templates in HTML Custom Elements

Already having a couple years each in React and Angular I decided to learn HTML Custom Elements, the browser's native component framework. "This should be easy," I thought, "Just tell me where to put the {{substitutions}} and I'll go from there."

Ha.

Point #1: such substitutions are part of a templating engine, which the native framework lacks. In this article I'll detail many choices in handling the HTML portion of a component, such as what format to keep it in, how to parameterize it, when to use the <template> tag, how to render and then re-render it, and how to know when to re-render it.

In these examples I'll be using single-file components with zero dependencies, so no fetching HTML separately from the imported code. I'll use a little bit of Typescript for documentation purposes. I'll also hold the preference of always using the Shadow DOM, and always keeping the HTML/CSS at the bottom of the code file, not because it is easy but because it is hard.

Our example is a <flex-row> component, which merely styles its children in a flexbox's row alignment. Since it accepts child nodes as input, we'll see the <slot> tag instructing the native framework where to put those children. Example usage:

<flex-row>
  <div>Left sidebar.</div>
  <div>Main content, containing a very long text that wraps around the viewport multiple times. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</div>
</flex-row>
Enter fullscreen mode Exit fullscreen mode

Simplest Example

customElements.define('flex-row', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start;"></slot>`;
  }
})
Enter fullscreen mode Exit fullscreen mode

By inlining the class definition right inside the final .define call, we don't actually need to name the class. .attachShadow creates the Shadow DOM, and returns a reference to it which we can immediately .innerHTML it with a string which is our initial template.

The /*html*/ comment is a hint to our IDE that the backtic string should be color-syntax highlighted as HTML. CSS is inlined but a <style></style> after the </slot> works just as well. But if our template is large we'd probably move it out of the code to the bottom of the file...

Put Template At Bottom

customElements.define('flex-row', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).innerHTML = template();
  }
})

function template() {
  return /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start;"></slot>`;
}
Enter fullscreen mode Exit fullscreen mode

Moving the template to the end of the file looks cleaner. But, calling .define so early can error with template() is undefined if we used const template = () => .... So here I must use function so hoisting happens. Or I could name the class and move just the .define call to the very bottom... where it's easy to forget it.

Anyway, let's add a parameter <flex-box wrap='nowrap'> for our template to wrangle.

Add Parameter for flex-wrap

customElements.define('flex-row', class extends HTMLElement {
  connectedCallback() {
    const wrap = this.getAttribute('wrap');
    this.attachShadow({ mode: 'open' }).innerHTML = template(wrap);
  }
);

function template(flexWrap: string) {
  return /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;
}
Enter fullscreen mode Exit fullscreen mode

One difference in attaching the Shadow in connectedCallback instead of the constructor is that the element's attributes are ready. Our template function is parameterized like any function.

Now, using this feature is weird. Why <flex-box wrap='nowrap'> instead of just <flex-box nowrap> or <flex-box wrap>? It also never re-renders. Let's add both of these features.

Render Method and Property Watching

customElements.define('flex-row', class FlexRow extends HTMLElement {
  connectedCallback() {
    this.wrap = typeof this.getAttribute('wrap') === 'string' ? 'wrap' : typeof this.getAttribute('nowrap') === 'string' ? 'nowrap' : '';
    this.attachShadow({ mode: 'open' }).innerHTML = template(this.wrap);
  }

  #wrap = '';
  get wrap() { return this.#wrap; }
  set wrap(v) { this.#wrap = v; FlexRow.render(this); };

  static render({ shadowRoot, wrap }: FlexRow) {
    if (shadowRoot) shadowRoot.innerHTML = template(wrap);
  }
});

function template(flexWrap: string) {
  return /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;
}
Enter fullscreen mode Exit fullscreen mode

There's a lot to unpack here.

1) We check both attributes wrap and nowrap on init.
2) We store the appropriate string in a javascript private variable this.#wrap. (Typescript shims this since browser support isn't yet complete, but my later examples won't use it anyway.)
3) We create a getter/setter pair so when outside code changes the property we can react.
4) We declare a static render function for the re-render.

Now the consumer can initialize our component with short attributes like <flex-row wrap> and <flex-row nowrap> but then change them anytime with the property ourElement.wrap = 'nowrap';.

If we didn't make the backing field #wrap a private member, then the consumer could just hit it directly with ourElement._wrap = ... and be left wondering why nothing works, and why is there both a wrap and a _wrap property visible in the browser devtools.

Similarly, we hide our render function by making it static. This does require naming the class, but in return we can destructure the parameter to avoid a lot of this. prefixes.

Point #2: attributes and properties don't automatically mirror each other in HTML Custom Elements. Think of the .value of <input type=text value='initialVal' />: the attribute has the initial value and is effectively a constant, while only the property updates according to the user. Everything works that way with HTML Custom Elements.

But what if we wanted some mirroring, especially so CSS could alter itself based on whether the flex-row was currently wrapping or not? CSS only looks at attributes not properties...

Render Method and Property Watching II

class FlexRow extends HTMLElement {
  connectedCallback() {
    this.wrap = typeof this.getAttribute('wrap') === 'string' ? 'wrap' : typeof this.getAttribute('nowrap') === 'string' ? 'nowrap' : '';
    this.attachShadow({ mode: 'open' }).innerHTML = template(this.wrap);
  }

  get wrap() { return this.getAttribute('wrap'); }
  set wrap(v) { this.setAttribute('wrap', v || ''); render(this); };
}

const render = ({ shadowRoot, wrap }: FlexRow) => shadowRoot ? shadowRoot.innerHTML = template(wrap) : null

const template = (flexWrap: string | null) =>
  /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;

customElements.define('flex-row', FlexRow);
Enter fullscreen mode Exit fullscreen mode

A couple of things here.

Since we need to update the attribute whenever the property changes, the attribute might as well be the backing field.

For the sake of example I moved .define to the very bottom, allowing us to use arrow functions for template(). Since that's a pretty one-liner we'll do render() the same way as an alternative to the static.

The new features come with a couple of issues.

The nowrap attribute lacks a matching property, and the wrap property accepts the string "nowrap", which can confuse our users. I'll defer on this design issue since it's unrelated to templates but did want to call it out.

More importantly, later updates to the attributes via ourElement.attributes['wrap'] = 'wrap' or ourElement.setAttribute('wrap','wrap') will do nothing, even though the attributes worked initially, potentially disappointing our users.

Property AND Attribute Watching

class FlexRow extends HTMLElement {
  connectedCallback() {
    this.wrap = typeof this.getAttribute('wrap') === 'string' ? 'wrap' : typeof this.getAttribute('nowrap') === 'string' ? 'nowrap' : '';
    this.attachShadow({ mode: 'open' }).innerHTML = template(this.wrap);
  }

  get wrap() { return this.getAttribute('wrap'); }
  set wrap(v) { this.setAttribute('wrap', v || ''); render(this); };

  static observedAttributes = ['wrap', 'nowrap'];

  attributeChangedCallback(name: string, oldValue: string, newValue: string) {
    if (oldValue !== newValue) render(this);
  }
}

const render = ({ shadowRoot, wrap }: FlexRow) => shadowRoot ? shadowRoot.innerHTML = template(wrap) : null

const template = (flexWrap: string | null) =>
  /*html*/`<slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start; ${flexWrap ? `flex-wrap: ${flexWrap};` : ''}"></slot>`;

customElements.define('flex-row', FlexRow);
Enter fullscreen mode Exit fullscreen mode

Adding attributeChangedCallback, and listing the possible values for its first argument in the static (and required) observedAttributes allows us to react to attribute changes. Normally we need a this[name] = newValue; in that function to copy the value from the attribute to the property, but since our property uses a getter/setter already pointed to the attribute, there's no need. We of course must check for infinite loop.

I said at the start of this article there's no simple substitutions like in React and Angular. We've been using template string literals and .innerHTML to parse the string for us. This is fine if the component is never re-rendered, being both permanent and one-use, like a main-menu bar. But when you introduce re-rendering, the re-parsing of strings has a hefty performance hit.

Let's fix our performance problem with a single string parse for the initial render, and a more surgical update for re-rendering. We'll need the <template> HTML tag and the .cloneNode method for this one.

Precompile Template, Minimal Re-render

const template = /*html*/ `
<template>
    <slot style="display: flex; flex-direction: row; flex: 1 1 auto; align-items: flex-start;"></slot>
</template>`;

let fragment: DocumentFragment;

class FlexRow extends HTMLElement {
  static observedAttributes = ["wrap", "reverse"] as const;

  #elementsCache: {
    rootElement?: CSSStyleDeclaration;
  } = {};

  connectedCallback() {
    if (!fragment) fragment = new DOMParser().parseFromString(template, "text/html").head.querySelector("template")!.content;
    const myFragment = fragment.cloneNode(true) as DocumentFragment;
    this.#elementsCache.rootElement = (myFragment.firstElementChild as HTMLElement).style;
    for (const attribute of FlexRow.observedAttributes) this.attributeChangedCallback(attribute, "", this.getAttribute(attribute));
    this.attachShadow({ mode: "closed" }).appendChild(myFragment);
  }

  // reflect properties to attributes
  get wrap() {
    return this.getAttribute("wrap");
  }
  set wrap(value: string | null) {
    this.setAttribute("wrap", value || "");
  }

  get reverse() {
    return this.getAttribute("reverse");
  }
  set reverse(value: string | null) {
    if (value == null) this.removeAttribute("reverse");
    else this.setAttribute("reverse", value);
  }

  // reflect attributes to properties
  attributeChangedCallback(name: (typeof FlexRow.observedAttributes)[number], oldValue: string | null, newValue: string | null) {
    if (oldValue === newValue) return; // prevent infinite loop
    //console.log("name", name, "old", oldValue, "new", newValue);
    switch (name) {
      case "wrap":
        this.#elementsCache.rootElement!.setProperty(name, newValue);
        return;
      case "reverse":
        // newValue = null means attr is now missing;  newValue == "" means attribute there
        this.#elementsCache.rootElement!.setProperty("flex-direction", newValue == "" || (!!newValue && newValue != "false") ? "row-reverse" : "row");
        return;
    }
  }
}

customElements.define("flex-row", FlexRow);
Enter fullscreen mode Exit fullscreen mode

Our template() function is now just a variable no longer accepting parameters because it's only going to be used once. Its contents are wrapped in a <template> tag because now the DOMParser parses the text and returns the disconnected node tree. We then immediately querySelector it for the <template> tag, and put the content into a variable for use by every instance of <flex-row> afterward. It will never be re-parsed again.

Given the template that this particular instance of flex-row will use, we then find and cache each element or piece that changes at runtime in response to attribute/property changes. Here it's just the .style of the root element.

When a property changes, we reflect its new value into the corresponding attribute. When an attribute changes, the standard attributeChangedCallback is called. It's here where we update our component's HTML. We also call this from a loop in connectedCallback on initialization to remove all dummy values that were cloned from the static template.

Note that if oldValue or newValue are null, this means the attribute didn't exist at all, which is a distinct case from it existing but with an empty string.

This way is a bit more typing and complexity, but on re-render, that sole setProperty is astronomically faster than creating and parsing strings, especially if said string invokes other custom components.

Top comments (0)