DEV Community

Ron Newcomb
Ron Newcomb

Posted on • Updated 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. Our HTML and its preparatory steps still go at the bottom of the file.

Precompile Template, Minimal Re-render

class FlexRow extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' }).appendChild(createdTemplateNode!.content.cloneNode(true));
    updateTemplateNode(this);
  }

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

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

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

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

const createdTemplateNode = new DOMParser().parseFromString(txt, 'text/html').head.querySelector('template');

const updateTemplateNode = ({ shadowRoot, wrap }: FlexRow) => {
  const node = shadowRoot?.firstElementChild as HTMLElement;
  if (!node) return;
  if (wrap) node.style.setProperty('flex-wrap', wrap);
  else node.style.removeProperty('flex-wrap');
}

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

We've renamed render to updateTemplateNode in order to parallel the naming of createdTemplateNode as well as to avoid implying render is the whole story.

Our template() function is now just a variable called txt and no longer accepts 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 then that tree will be, from now on, what we clone on every instance of <flex-row>.

Once the brunt of our template is in place, we then use updateTemplateNode to touch it only in the places that need changing. Since this example only has the one flex-wrap parameter, our update is just a single line to set or remove the style property from the correct node, which is always first child node off the root.

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

Which Is Best?

I don't actually recommend using the last example at all times. One of the perks of HTML Custom Elements over non-native UI frameworks is that we can make a component with zero dependencies, zero library. This is great for creating micro-frontends. But when we try to optimize for speed, the extra code itself costs memory and bootstrap time, and that cost multiplies if no library can share the same code among all components.

Thank you for reading this far. I hope you found my little journey interesting.

Discussion (0)