Image for post
Image for post

The Case for React-like Web Components

I dig React. My favorite feature of React is the emphasis on building small blocks of UI in JSX, and then combining those pieces together to create an app.

Two years ago I wrote about how the choice for Vanilla JavaScript is more compelling than ever before because of new technologies such as Fetch, Promises, template literals, and ECMAScript Modules. That last one is key, because at the time of my post no browser actually supported module loading.

Today, the situation is thankfully much different. Edge 16+, Chrome 61+, and Safari 10.1+ all support ES Modules natively! Firefox is the odd duck out (relevant bug here), but feature detection for ES Modules is really easy (use <script nomodule> for unsupported browsers and <script type=”module”> for supported ones.)

Combined with existing advances such as ES Classes, template literals, and Web Components (specifically Custom Elements and Shadow DOM), ES Modules were the final piece of the puzzle for React-like self-contained UI components.

Disclaimer

This post is about my adventures in Vanilla JS and Web Components, and ideas I’ve had while making some small web projects. I may get some things wrong or accidentally endorse an anti-pattern. Feedback and constructive criticism are always helpful!

Creating a React-like Web Component

Take the following React component from the official documentation:

class Greeting extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}

Thanks to JSX, you’re able to easily compose HTML and JavaScript together.

Let’s look at the Vanilla JS version using ES Modules and Web Components:

export class GreetingView extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `<h1>Hello, ${this.getName()}</h1>`;
}
// Other code, including getName()...
}
window.customElements.define('greeting-view', GreetingView);

As you can see, it’s not as elegant and there are some problems (addressed below) which detract from making a direct 1:1, line-for-line comparison. But this is still really powerful for a “bare-metal” JavaScript implementation.

Note: Custom Elements contain a dash between the first and last characters. This is to differentiate them from the built-in HTML elements such as <input>, etc. It’s good practice to namespace your Web Components (the examples in this post use “fancy-*,” for example.)

Composing with Web Components

Since Web Components are merely fancy HTML elements, it’s easy to use them together to build rich interfaces.

Let’s say we want to make our own, better <button> element:

export class FancyButton extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<button>
<slot></slot>
</button>
`;
}
}
window.customElements.define('fancy-button', FancyButton);

In Web Components, you can declare <slot> elements which will contain the HTML between your custom element’s opening and closing tags. Let’s see that in action:

import {FancyButton} from './FancyButton.js';export class FancyView extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<article>This is my view!</article>
<fancy-button>This is my fancy button</fancy-button>
`;
}
}
window.customElements.define('fancy-view', FancyView);

In this example, the text “This is my fancy button” will be inserted into our Web Component’s <button> element.

You can also take the programmatic approach:

import {FancyButton} from './FancyButton.js';export class FancyView extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<article>This is my view!</article>
`;
const button = new FancyButton();
shadowRoot.appendChild(button);
}
}
window.customElements.define('fancy-view', FancyView);

Composing HTML pages using DOM elements has always been a part of the Web, and Web Components build on that rock-solid foundation.

Note: <slot> does not seem to be supported in Edge yet. 😓

Modularized CSS

As a bonus, you also get like “CSS Modules” for free due to the Shadow DOM (the variable shadowRoot in the above example.) By default, no styles can penetrate or escape from the Shadow DOM, so there’s no downside to doing something like this:

shadowRoot.innerHTML = `
<style>
:root {
box-shadow: 1px 1px 1px gray;
}
h1 {
color: red;
}
</style>
<article>
<h1>Hello, ${this.getName()}</h1>
<p>
${this.getContent()}
</p>
</article>
`;

A caveat is that because styles don’t penetrate, that means in the example above even if the parent page has this:

button {
background: purple;
}

Our <fancy-button> element will show the default, unstyled <button> element instead. If you want to get that <button> styled from the parent page, you’d have to do something like this:

fancy-button {
background: purple;
}

And then in our Web Component:

shadowRoot.innerHTML = `
<style>
button {
background: inherit;
}
</style>
<button>
<slot></slot>
</button>
`;

However, the inherit property can really only go so far when using it this way.

Unfortunately when using the Shadow DOM, you’re also not able to share CSS Variables across components. That is a bummer. But there is of a workaround…

Problem A: Styles from the parent page do not cascade into the Shadow DOM

A problem arises when you’re composing a view or a screen and the styles for the page don’t cascade into your Web Component. As mentioned, this is by design. I think in most cases, you should pull as much of each component’s CSS into its own module — it’s the same principle as shying away from global variables.

But, if there some things you really need to cascade down, the following workaround can get you there:

export class GreetingView extends HTMLElement {
connectedCallback() {
this.innerHTML = `<h1>Hello, ${this.getName()}</h1>`;
}
}
window.customElements.define('greeting-view', GreetingView);

this.innerHTML bypasses the Shadow Root and exposes all of your component’s HTML to the parent DOM.

I’ve found this pattern is most useful when you’re building your entire app with Web Components and you don’t expect to to share them outside that project. Otherwise, if you intend to open source your Web Components or use them across projects the Shadow DOM method is the way to go.

Problem B: There’s no data-binding

You don’t get data binding for free in Vanilla JS. You can see in my examples that I had to use this.getName() instead of React’s cleaner this.props.name (which automatically updates with changes.) Proxies might be able to do the job, but I haven’t looked into them much yet. Point: React.

Problem C: You can’t extend native elments like <button> or <input>

You can’t… It’s on the list of things for browsers to do, and I recommend starring or +1'ing the following issues so they know the demand is there:

Chromium: “Launch customized built-in elements”

Web Components: “Support extending elements”

As it stands, my efforts above to “build a better <button>” have involved wrapping one inside of a Web Component instead of extending from HTMLButtonElement, which would be far nicer.

“Should I throw out React/Vue/etc. and start over with Vanilla JS?”

Probably not. React is a robust, battle-tested framework backed by one of the largest Web engineering organizations of today. And even if you’re starting out with a new project, React and other frameworks can still fill gaps that Vanilla JS can not.

However, if your main attraction to React (and Vue, et al) is the ability to compose standalone components with HTML inside, then take a serious look at Web Components. They’re less magic, but are now worth considering for new projects.

😁

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store