Converting Lit Components to Enhance
by Simon MacDonald
@macdonst
@macdonst@mastodon.online
on
Original photo by Skye Studios on Unsplash
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.
- 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.
- Since web components are written in JavaScript, it’s very difficult to do progressive enhancement.
- Reduce client-side dependencies
- 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);
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>
The above HTML produces a page that looks like this:
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>`
}
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>
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.
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>
Now, anytime you click on the component, the planet name will be toggled:
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>`
}
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
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')
}
})
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>
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:
- You are re-rendering the entire element
- 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.mjs
file 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')
}
})
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.