DOM components that follow the separation of concerns dictum

Solving the Trilingual Challenge

by Joe Honton, Read Write Tools

The three-in-one challenge

Getting started with DOM components is straightforward. An afternoon of research reveals the basic landscape: custom elements, shadow DOM, HTML <template> elements, and JavaScript modules.

The lay of the land is mapped out in both tutorials and tech specs, so we don't need to go over it again. But there are a few bumps in the road that haven't been adequately sign-posted, which challenges us to find detours on our own. One of these challenges is how to adhere to the well-known separation of concerns policy.

As my own understanding of the DOM component landscape has progressed, my work has coalesed into a common pattern that meets that challenge, which I've charted here.

Separation of concerns pattern

For me, an important aspect of DOM components is a stong bias against placing HTML and CSS inside JavaScript files. Plenty of tutorials show how to use multiline JavaScript template literals to embed HTML and CSS — but that never felt right to me. Template literals don't have the benefit of syntax highlighting, linting, debugging, and compliance checking.

So my components have separate files for scripts, templates and styles. Most simple components are composed of just three files, while sophisticated components may split their JavaScript code into multiple modules.

A hypothetical component named rwt-foo would be structured like this.

Notably, these components have no abstract classes or framework code. Everything that is needed by the browser to define, decorate, and interact with the component is self-contained. This makes it possible to distribute and use the component in new settings without dependencies.

To access the component, the three files are placed in a known location on the web server. I like to put them in a directory named node_modules so that I can take advantage of the easy publish/distribute/install tools provided by NPM. But this is just a convention. NPM's module loader is never invoked by the browser, so the component's files can be placed in any directory that the browser has access to.

The browser downloads, parses and interprets the component when it encounters a script like this somewhere in the document:

<script src='/node_modules/rwt-foo/rwt-foo.js' type=module></script>

The component can be declared and placed within the document's body using a statement like this:

<rwt-foo></rwt-foo>    

The above two lines of code are all that's needed within the document. But they only load the rwt-foo.js JavaScript file and its dependent modules. The HTML template and CSS stylesheet still need to be loaded. Loading those two resources should be done using Ajax within the component's connectedCallback initializer.

The fetch and cache pattern

Here's the fetch and cache pseudo-code used to get the component's HTML template:

Key points:

  • The getHtmlFragment function returns a Promise to an HTML fragment. Callers should await its fulfillment.
  • This pattern provides a caching mechanism for the HTML fragment, so that only the first component instance needs to make the network request to the server.
  • Each instance begins by setting up a listener for the rwt-foo-html-template-ready message.
  • On the first instance only, an asynchronous fetch is issued for the template located at htmlURL. As the response is streamed to the browser, it is saved to the static cache htmlText. When complete, it broadcasts a rwt-foo-html-template-ready message.
  • Each instance that is listening for the broadcast message calls an inner closure that creates an HTML <template> element, copies the contents of the static cache into the element's innerHTML, and finally resolves the outer Promise with the template's content.

The same fetch and cache pattern is used by getCssStyleElement to get the CSS stylesheet:

Async initialization pattern

DOM components do not block the execution of the main thread. This means that we must somehow initialize the component and put it into a stable state before sending it requests. It's the component's responsibility to let us know when it is fully loaded and ready for use. Here's an outline of an asynchronous pattern that does that.

When the browser parses and interprets the JavaScript module it encounters a line like this at the very end:

window.customElements.define("rwt-foo", RwtFoo);

The first argument is the HTML custom element name. The second argument is the name of the JavaScript class that implements the component's features. This is what links HTML's <rwt-foo></rwt-foo> to JavaScript's RwtFoo class.

The browser does two things. 1) It calls the class constructor to create an object instance and to declare all of the class properties. 2) It calls the object's connectedCallback function to initialize the component.

Here's a walk-through of the four things I put in every connectedCallback function.

First are the two asynchronous calls to fetch the HTML template and the CSS stylesheet, using the pattern just laid out.

Second is the creation of the component's shadow DOM element tree, which is disconnected from the document's main DOM tree and separated by a firewall. The HTML fragment and the CSS stylesheet created in the first step are then added to the shadow DOM tree. From this point forward, all child element references and listeners use the shadowRoot variable to access the component's HTML elements and CSS selectors.

Third is the identification of each named element that the component will be accessing or manipulating while performing its work. These are saved as properties of the RwtFoo class, so that they are available for use by any of its worker methods. Identification is carried out using the getElementById method of shadowRoot. For example, an HTML element like <button id='bar'> would be identified using a statement like:

this.bar = this.shadowRoot.getElementById('bar');

Any event listeners that need to be registered are setup using these class properties. When doing this it's important to remember to bind the callback functions to the class instance, so that they have access to any properties they may need. For example:

this.bar.addEventListener('click', this.onClickBar.bind(this));

Many components will have additional initialization work beyond these three essentials steps. Every component has different needs, so abstracting that work into a standard callback is counterproductive. Instead, simply insert any special functions where it makes most sense, between steps one, two and three.

The final initialization step is to broadcast the rwt-foo-component-loaded message so that any asynchronous awaits can be resolved. As a convenience, external code can call the waitOnLoading function which returns a Promise that resolves when the broadcast is received.


Some popular component frameworks have ignored the importance of having separate languages for scripts, templates and styles. Instead, they've regressed to the older all-in-one model that was popular twenty years ago.

It doesn't have to be that way. The three patterns outlined here can get us back on the right road.

  • The separation of concerns pattern: JavaScript, HTML and CSS each in their own file.
  • The fetch and cache pattern: retrieval and local caching of templates and style sheets.
  • The async initialization pattern: putting resources, properties and listeners into a ready state.
Read Write Tools icon

Smart tech

READ WRITE TOOLS

Read Write Hub icon

Distraction-free writing

RWDOC

BLUEPHRASE icon

Rediscover HTML

BLUE PHRASE

Read Write Serve icon

Served with pure HTTP/2

RWSERVE

DOM components that follow the separation of concerns dictum

🔗 🔎