DEV Community

Cover image for Converting Lit Components to Enhance
Simon MacDonald for Begin

Posted on • Originally published at blog.begin.com

Converting Lit Components to Enhance

Last month we talked about using a Lit Component in an Enhance app. In this post, we’ll show you how to convert a Lit component into an Enhance component.

Why convert from Lit to Enhance?

Lit is a fine framework for building web components, but there are a few reasons you may want to convert a Lit component into an Enhance component.

  1. Flash of unregistered custom element (FOUCE). Since we have to wait until the custom element is registered you may see a brief flash of unstyled HTML as you page loads.
  2. Since web components are written in JavaScript, it’s very difficult to do progressive enhancement.
  3. Reduce client-side dependencies
  4. Remove TypeScript build step.

Our Lit Component

We’ll re-use the Lit component from the last post. Here’s the source as a reminder.

// public/my-element.js
import {LitElement, html, css} from 'lit';
export class MyElement extends LitElement {
 static properties = {
   greeting: {},
   planet: {},
 };
 static styles = css`
  :host {
    display: inline-block;
    padding: 10px;
    background: lightgray;
  }
  .planet {
    color: var(--planet-color, blue);
  }
`;

 constructor() {
   super();
   this.greeting = 'Hello';
   this.planet = 'World';
 }

 render() {
   return html`
    <span @click=${this.togglePlanet}
      >${this.greeting}
      <span class="planet">${this.planet}</span>
    </span>
  `;
 }

 togglePlanet() {
   this.planet = this.planet === 'World' ? 'Mars' : 'World';
 }
}
customElements.define('my-element', MyElement);
Enter fullscreen mode Exit fullscreen mode

Now, we can use Lit components just like our Enhance components. For example, let’s create a new page app/pages/lit.html and populate it with the following lines:

<script type="module" src="/_public/my-element.js"></script>
<style>
 .mars {
   --planet-color: red;
 }
</style>
<my-element></my-element>
<hr />
<my-element class="mars" planet="Mars"></my-element>
Enter fullscreen mode Exit fullscreen mode

The above HTML produces a page that looks like this:

Lit Demo

Solving the FOUCE problem

To make sure we don’t run into the FOUCE issue we’ll server-side render our component. This way, as soon as the page is rendered, our web component will be rendered with default content. Our Enhance version of the Lit component will look like this:

// app/elements/my-element.mjs
export default function Element ({ html, state }) {
 const { attrs } = state
 const { greeting = "Hello", planet = 'World'} = attrs
 return html`
<style>
:host {
 display: inline-block;
 background: lightgray;
}
.planet {
 color: var(--planet-color, blue);
}
</style>
<span>
 ${greeting}
 <span class="planet">${planet}</span>
</span>`
}
Enter fullscreen mode Exit fullscreen mode

Then we can remove the script tag that points to our Lit version of the component.

<style>
 .mars {
   --planet-color: red;
 }
</style>
<my-element></my-element>
<hr />
<my-element class="mars" planet="Mars"></my-element>
Enter fullscreen mode Exit fullscreen mode

Now when the page is loaded their is no FOUCE as our component styles have been hoisted to the head and we’ve sent our default content down the wire for the browser to render.

lit demo non-interactive

The only problem is we have no interactivity. When you click on either of the messages, the togglePlanet method is not fired as it doesn’t currently exist. However, we can fix this in the next step as Enhance excels at progressive enhancement.

Progressive Enhancement

Now that we have a server-side rendered version of our component that solves FOUCE and is displayed with or without JavaScript let’s get started adding interactivity to this component via progressive enhancement.

We’ll add a script tag to our single file component, which will load if and when JavaScript is available, adding interactivity to our component.

<script type="module">
 class MyElement extends HTMLElement {
   constructor() {
     super()
     this.planetSpan = this.querySelector('.planet')
     this.planetSpan.addEventListener('click', this.togglePlanet.bind(this))
   }

   static get observedAttributes() {
     return [ 'planet' ]
   }

   attributeChangedCallback(name, oldValue, newValue) {
     if (oldValue !== newValue) {
       if (name === 'planet') {
         this.planetSpan.textContent = newValue
       }
     }
   }

   togglePlanet() {
     let planet = this.getAttribute('planet') || 'World'
     this.planet = planet === 'World' ? 'Mars' : 'World';
   }

   set planet(value) {
     this.setAttribute('planet', value);
   }
 }

 customElements.define('my-element', MyElement)
</script>
Enter fullscreen mode Exit fullscreen mode

Now, anytime you click on the component, the planet name will be toggled:

Lit Demo

Since we have implemented a plain vanilla web component, you can remove the Lit dependency. Lit has a relatively small bundle size, just 16.5 kb minified according to Bundlephobia, but every byte of JavaScript you remove from the client side helps with performance.

Also, you don’t need TypeScript, so you can remove that transpilation step in your build process to convert TypeScript into JavaScript.

Syntactical Sugar

But what if you really like the syntactical sugar that TypeScript or Lit Element give you? Well, you are in luck, as you can use the @enhance/element package to rid yourself of some boilerplate code.

The first step is to remove that script tag from your app/elements/my-element.mjs file so that it looks like this:

export default function Element ({ html, state }) {
 const { attrs } = state
 const { greeting = "Hello", planet = 'World'} = attrs
 return html`
<style>
:host {
 display: inline-block;
 background: lightgray;
}
.planet {
 color: var(--planet-color, blue);
}
</style>
<span>
 ${greeting}
 <span class="planet">${planet}</span>
</span>`
}
Enter fullscreen mode Exit fullscreen mode

Effectively we are back to where we started when we first server-side rendered our component. The component in this state is not interactive.

We’ll need to add a new dependency to our project so run:

npm install @enhance/element
Enter fullscreen mode Exit fullscreen mode

Now create a new file app/browser/my-element.mjs where we will contain our client-side code.

// app/browser/my-element.mjs
import enhance from '@enhance/element'

enhance('my-element, {
 attrs: [ 'planet' ],
 init(el) {
   this.planetSpan = el.querySelector('.planet')
   this.planetSpan.addEventListener('click', this.togglePlanet.bind(this))
 },
 render({ html, state }) {
   const { attrs={} } = state
   const { greeting='Hello', planet='World' } = attrs
   return html`
       <span>
         ${greeting}
         <span class="planet">${planet}</span>
       </span>
       `
 },
 togglePlanet() {
   let planet = this.getAttribute('planet') || 'World'
   this.setAttribute('planet', planet === 'World' ? 'Mars' : 'World')
 }
})
Enter fullscreen mode Exit fullscreen mode

Finally, you’ll need to add a script tag to any HTML page you use my-element in.

<script type="module" src="/_public/pages/my-element.mjs"></script>
Enter fullscreen mode Exit fullscreen mode

You’ll notice with this syntactical sugar version that we don’t need to add boilerplate code for observedAttributes, attributeChangedCallback and attribute setters as @enhance/element handles this for you.

However, you may have noticed that the render function in your client-side code mirror your server-side code. It seems wasteful for two reasons:

  1. You are re-rendering the entire element
  2. You are duplicating the render method in two spaces

Your first concern is invalid as Enhance Element does DOM diffing for you and only updates the parts of the DOM that have been changed, but how would you know that if I didn’t tell you?

The second point is more than valid so let’s remove that duplication.

Remove Duplication

We’ll update ourapp/browser/my-element.mjsfile with the following contents:

import enhance from '@enhance/element'
import Element from '../elements/my-element-sugar.mjs'

enhance('my-element, {
 attrs: [ 'planet' ],
 init(el) {
   this.planetSpan = el.querySelector('.planet')
   this.planetSpan.addEventListener('click', this.togglePlanet.bind(this))
 },
 render: Element,
 togglePlanet() {
   let planet = this.getAttribute('planet') || 'World'
   this.setAttribute('planet', planet === 'World' ? 'Mars' : 'World')
 }
})
Enter fullscreen mode Exit fullscreen mode

The render function of our new file will be our previously created pure function for server-side rendering our web component. Enhance will make this file available under public/pages/my-element.mjs.

In conclusion

With a bit of extra work you can avoid common web component issues like FOUCE, while retaining interactivity. You can also reduce the complexity of your application by removing unnecessary builds steps.

Top comments (0)