From Web Component to Lit Element

Last Updated: 2020-10-28

Web Components

Web Components are a set of web platform APIs that allow you to create new custom, reusable, encapsulated HTML tags to use in web pages and web apps. Custom components and widgets built on the Web Component standards will work across modern browsers and can be used with any JavaScript library or framework that works with HTML.

LitElement

LitElement is a simple base class for creating fast, lightweight Web Components that work in any web page with any framework.

LitElement provides APIs to simplify common Web Components tasks like managing properties, attributes, and rendering.

What you'll learn

  • What is a Web Component
  • The concepts of Web Components
  • How to build a Web Component
  • What are lit-html and LitElement
  • How to build a LitElement component

What you'll build

  • A vanilla thumbs up / down Web Component
  • A thumbs up / down LitElement-based Web Component

What you'll need

  • Any updated modern browser (Chrome, Safari, Firefox, Chromium Edge). Web Components work in all modern browsers and polyfills are available for Microsoft Internet Explorer 11 and non-chromium Microsoft Edge.
  • Knowledge of HTML, CSS, JavaScript, and Chrome DevTools.

Accessing the Code

You can access the code here on glitch.com, Glitch is a website that provides an online web coding environment.

Exploring the Glitch UI

The show button is labeled as Section 1, the file explorer is labeled as Section 2, The code editing section as Section 3, the output preview as Section 4, and the preview reload button as Section 5

In the above Glitch UI screenshot you highlight the sections that are likely to be important for your development. The Section 1, on the top, "Show" button will allow you to preview the output. The above screenshot is using the "Next to The Code" option. Section 2, on the left, is the file explorer, section 3, in the center is the file editor, section 4, on the right, is the code preview, and section 5 is the reload button.

When you see the text This page has successfully loaded! Then your glitch instance has finished installing dependencies and the server has started.

Understanding the Project

In this project you have an index.html file:

index.html

<!DOCTYPE html>
<html>
 <head>
   <script src="./index.js" type="module"></script>
 </head>
 <body>
 </body>
</html>

This file only does one thing: it imports an index.js file as an ES module.

index.js

document.body.innerHTML = '<div>This page has successfully loaded!</div>';

This file has some placeholder code to let you know that the glitch instance is ready.

Your Glitch project also includes an images directory with thumbs up and down SVG files.

Finally, there is the package.json file used by npm to install dependencies. The project has only three dependencies installed which are: lit-element, lit-html, and es-dev-server which is a simple web server that will resolve bare module specifiers in javascript files.

Custom Elements

Web Components are a collection of 4 native web APIs. They are:

  • ES Modules
  • Custom Elements
  • Shadow DOM
  • HTML Templates

You've already used the ES modules specification, which allows you to create javascript modules with imports and exports that are loaded into the page with <script type="module">.

Defining a Custom Element

The Custom Elements specification lets users define their own HTML elements using JavaScript. The names must contain a hyphen (-) to differentiate them from native browser elements. Clear the index.js file and define a custom element class:

index.js

class RatingElement extends HTMLElement {}

customElements.define('rating-element', RatingElement);

A custom element is defined by associating a class that extends HTMLElement with a hyphenated tag name. The call to customElements.define tells the browser to associate the class RatingElement with the tagName ‘rating-element'. This means that every element in your document with the name <rating-element> will be associated with this class.

Place a <rating-element> in the document body and see what renders.

index.html

<body>
 <rating-element></rating-element>
</body>

Now, looking at the output, you'll see that nothing has rendered. This is expected, because you haven't told the browser how to render <rating-element>. You can confirm that the Custom Element definition has succeeded by selecting the <rating-element> in Chrome Dev Tools' element selector and, in the console, calling:

$0.constructor

Which should output:

class RatingElement extends HTMLElement {}

Custom Element Lifecycle

Custom Elements come with a set of lifecycle hooks. They are:

  • constructor
  • connectedCallback
  • disconnectedCallback
  • attributeChangedCallback
  • adoptedCallback

The constructor is called when the element is first created: for example, by calling document.createElement(‘rating-element') or new RatingElement(). The constructor is a good place to set up your element, but it is typically considered bad practice to do DOM manipulations in the constructor for element "boot-up" performance reasons.

The connectedCallback is called when the custom element is attached to the DOM. This is typically where initial DOM manipulations happen.

The disconnectedCallback is called after the custom element is removed from the DOM.

The attributeChangedCallback(attrName, oldValue, newValue) is called when any of the user-specified attributes change.

The adoptedCallback is called when the custom element is adopted from another documentFragment into the main document via adoptNode such as in HTMLTemplateElement.

Render DOM

Now, return to the custom element and associate some DOM with it. Set the element's content when it gets attached to the DOM:

index.js

class RatingElement extends HTMLElement {
 constructor() {
   super();
   this.rating = 0;
 }
 connectedCallback() {
   this.innerHTML = `
     <style>
       rating-element {
         display: inline-flex;
         align-items: center;
       }
       rating-element button {
         background: transparent;
         border: none;
         cursor: pointer;
       }
     </style>
     <button class="thumb_down" >
       <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
     </button>
     <span class="rating">${this.rating}</span>
     <button class="thumb_up">
       <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
     </button>
   `;
 }
}

customElements.define('rating-element', RatingElement);

In the constructor, you store an instance property called rating on the element. In the connectedCallback, you add DOM children to <rating-element> to display the current rating, together with thumbs up and thumbs down buttons.

Why Shadow DOM?

In the previous step, you'll notice that the selectors in the style tag that you inserted select any rating element on the page as well as any button. This may result in the styles leaking out of the element and selecting other nodes that you may not intend to style. Additionally, other styles outside of this custom element may unintentionally style the nodes inside your custom element. For example, try putting a style tag in the head of the main document:

index.html

<!DOCTYPE html>
<html>
 <head>
   <script src="./index.js" type="module"></script>
   <style>
     span {
       border: 1px solid red;
     }
   </style>
 </head>
 <body>
   <rating-element></rating-element>
 </body>
</html>

Your output should have a red border box around the span for the rating. This is a trivial case, but the lack of DOM encapsulation may result in larger problems for more complex applications. This is where Shadow DOM comes in.

Attaching a Shadow Root

Attach a Shadow Root to the element and render the DOM inside of that root:

index.js

class RatingElement extends HTMLElement {
 constructor() {
   super();
   this.rating = 0;
 }
 connectedCallback() {
   const shadowRoot = this.attachShadow({mode: 'open'});
  
   shadowRoot.innerHTML = `
     <style>
       :host {
         display: inline-flex;
         align-items: center;
       }
       button {
         background: transparent;
         border: none;
         cursor: pointer;
       }
     </style>
     <button class="thumb_down" >
       <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
     </button>
     <span class="rating">${this.rating}</span>
     <button class="thumb_up">
       <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
     </button>
   `;
 }
}

customElements.define('rating-element', RatingElement);

When you refresh the page, you will notice that the styles in the main document can no longer select the nodes inside the Shadow Root.

How did you do this? In the connectedCallback you called this.attachShadow which attaches a shadow root to an element. The open mode means that the shadow content is inspectable and makes the shadow root accessible via this.shadowRoot as well. Take a look at the Web Component in the Chrome inspector as well:

570cd1a601f9a631.png

You should now see an expandable shadow root that holds the contents. Everything inside that shadow root is called the Shadow DOM. If you were to select the rating element in Chrome Dev Tools and call $0.children, you will notice that it returns no children. This is because Shadow DOM is not considered a part of the same DOM tree as direct children but rather the Shadow Tree.

Light DOM

An experiment: add a node as a direct child of the <rating-element>:

index.html

<rating-element>
 <div>
   This is the light DOM!
 </div>
</rating-element>

Refresh the page, and you'll see that this new DOM node in this Custom Element's Light DOM does not show up on the page. This is because Shadow DOM has features to control how Light DOM nodes are projected into the shadow dom via <slot> elements.

Why Templates

Using innerHTML and template literal strings with no sanitization may cause security issues with script injection. Methods in the past have included using DocumentFragments, but these also come with other issues such as images loading and scripts running when the templates are defined as well as introducing obstacles for reusability. This is where the <template> element comes in; templates provide inert DOM, a highly performant method to clone nodes, and reusable templating.

Using Templates

Next, transition the component to use HTML Templates:

index.html

<body>
 <template id="rating-element-template">
   <style>
     :host {
       display: inline-flex;
       align-items: center;
     }
     button {
       background: transparent;
       border: none;
       cursor: pointer;
     }
   </style>
   <button class="thumb_down" >
     <svg xmlns="http://www.w3.org/2000/svg" height="24" viewbox="0 0 24 24" width="24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
   </button>
   <span class="rating"></span>
   <button class="thumb_up">
     <svg xmlns="http://www.w3.org/2000/svg" height="24" viewbox="0 0 24 24" width="24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
   </button>
 </template>

 <rating-element>
   <div>
     This is the light DOM!
   </div>
 </rating-element>
</body>

Here you moved the DOM content into a template tag in the main document's DOM. Now refactor the custom element definition:

index.js

class RatingElement extends HTMLElement {
 constructor() {
   super();
   this.rating = 0;
 }
 connectedCallback() {
   const shadowRoot = this.attachShadow({mode: 'open'});
   const templateContent = document.getElementById('rating-element-template').content;
   const clonedContent = templateContent.cloneNode(true);
   shadowRoot.appendChild(clonedContent);
  
   this.shadowRoot.querySelector('.rating').innerText = this.rating;
 }
}

customElements.define('rating-element', RatingElement);

To use this template element, you query the template, get it's contents, and clone those nodes with templateContent.cloneNode where the true argument performs a deep clone. You then initialize the dom with the data.

Congratulations, you now have a Web Component! Unfortunately it does not do anything yet, so next, add some functionality.

Property Bindings

Currently, the only way to set the rating on the rating-element is to construct the element, set the rating property on the object, and then put it on the page. Unfortunately, this is not how native HTML elements tend to work. Native HTML elements tend to update with both property and attribute changes.

Make the custom element update the view when the rating property changes by adding the following lines:

index.js

constructor() {
  super();
  this._rating = 0;
}

set rating(value) {
  this._rating = value;
  if (!this.shadowRoot) {
    return;
  }

  const ratingEl = this.shadowRoot.querySelector('.rating');
  if (ratingEl) {
    ratingEl.innerText = this._rating;
  }
}

get rating() {
  return this._rating;
}

You add a setter and getter for the rating property, and then you update the rating element's text if it's available. This means if you were to set the rating property on the element, the view will update; give it a quick test in your Dev Tools console!

Attribute Bindings

Now, update the view when the attribute changes; this is similar to an input updating its view when you set <input value="newValue">. Luckily, the Web Component lifecycle includes the attributeChangedCallback. Update the rating by adding the following lines:

index.js

static get observedAttributes() {
 return ['rating'];
}

attributeChangedCallback(attributeName, oldValue, newValue) {
 if (attributeName === 'rating') {
   const newRating = Number(newValue);
  
   this.rating = newRating;
 }
}

In order for the attributeChangedCallback to trigger, you must set a static getter for RatingElement.observedAttributes which defines the attributes to be observed for changes. You then set the rating declaratively in the DOM. Give it a try:

index.html

<rating-element rating="5"></rating-element>

The rating should now be updating declaratively!

Button Functionality

Now all that's missing is the button functionality. The behavior of this component should allow the user to provide a single up or down vote rating and give visual feedback to the user. You can implement this with some event listeners and a reflecting property, but first update the styles to give visual feedback by appending the following lines:

index.html

<style>
...

 :host([vote=up]) .thumb_up {
   fill: green;
 }
  :host([vote=down]) .thumb_down {
   fill: red;
 }
</style>

In Shadow DOM the :host selector refers to the node or custom element that the Shadow Root is attached to. In this case, if the vote attribute is "up" it will turn the thumb-up button green, but if vote is "down", then it will turn the thumb-down button red. Now, implement the logic for this by creating a reflecting property / attribute for vote similar to how you implemented rating. Start with the property setter and getter:

index.js

constructor() {
  super();
  this._rating = 0;
  this._vote = null;
}

set vote(newValue) {
  const oldValue = this._vote;
  if (newValue === oldValue) {
    return;
  }
  if (newValue === 'up') {
    if (oldValue === 'down') {
      this.rating += 2;
    } else {
      this.rating += 1;
    }
  } else if (newValue === 'down') {
    if (oldValue === 'up') {
      this.rating -= 2;
    } else {
      this.rating -= 1;
    }
  }
  this._vote = newValue;
  this.setAttribute('vote', newValue);
}

get vote() {
  return this._vote;
}

You initialize the _vote instance property with null in the constructor, and in the setter you check if the new value is different. If so, you adjust the rating accordingly and, importantly, reflect the vote attribute back to the host with this.setAttribute. Next, set up the attribute binding:

index.js

static get observedAttributes() {
  return ['rating', 'vote'];
}

attributeChangedCallback(attributeName, oldValue, newValue) {
  if (attributeName === 'rating') {
    const newRating = Number(newValue);
  
    this.rating = newRating;
  } else if (attributeName === 'vote') {
    this.vote = newValue;
  }
}

Again, this is the same process you went through with the rating attribute binding; you add vote to the observedAttributes, and you set the vote property in the attributeChangedCallback. And now finally, add some click event listeners to give the buttons functionality!

index.js

constructor() {
 super();
 this._rating = 0;
 this._vote = null;
 this._boundOnUpClick = this._onUpClick.bind(this);
 this._boundOnDownClick = this._onDownClick.bind(this);
}

connectedCallback() {
  ...
  this.shadowRoot.querySelector('.thumb_up')
    .addEventListener('click', this._boundOnUpClick);
  this.shadowRoot.querySelector('.thumb_down')
    .addEventListener('click', this._boundOnDownClick);
}

disconnectedCallback() {
  this.shadowRoot.querySelector('.thumb_up')
    .removeEventListener('click', this._boundOnUpClick);
  this.shadowRoot.querySelector('.thumb_down')
    .removeEventListener('click', this._boundOnDownClick);
}

_onUpClick() {
  this.vote = 'up';
}

_onDownClick() {
  this.vote = 'down';
}

In the constructor you bind some click listeners to the element and keep the references around. In the connectedCallback you listen for click events on the buttons. In the disconnectedCallback you clean up these listeners, and on the click listeners themselves, you set vote appropriately.

Congratulations, you now have a fully-featured Web Component; try clicking on some buttons! The issue now is that my JS file is now reaching 96 lines, my HTML file 43 lines, and the code is quite verbose and imperative for such a simple component. This is where Google's Lit project comes in!

Code Checkpoint

You may remix this glitch project or continue with your own from here.

Why lit-html

First and foremost, the <template> tag is useful and performant, but it isn't packaged with the logic of the component thus making it difficult to distribute the template with the rest of the logic. Additionally the way template elements work inherently lend to imperative code, which in many cases, leads to less-readable code compared to declarative coding patterns.

This is where lit-html comes in! Lit html is a rendering system created by Google that allows you to write HTML templates in Javascript, then efficiently render and re-render those templates together with data to create and update DOM. It is similar to popular JSX and VDOM libraries but it runs natively in the browser and much more efficiently in many cases.

Using Lit HTML

Next, migrate the native Web Component rating-element to use lit-html which uses a Tagged Template Literal which are functions that take template strings as arguments with a special syntax. Lit-html then uses template elements under the hood to provide fast rendering as well as providing some sanitization features for security. Start by migrating the <template> in index.html into a lit-html template by adding a render() method to the webcomponent:

index.js

import {render, html} from 'lit-html';

class RatingElement extends HTMLElement {
  ...
  render() {
    if (!this.shadowRoot) {
      return;
    }
 
    const template = html`
      <style>
        :host {
          display: inline-flex;
          align-items: center;
        }
        button {
          background: transparent;
          border: none;
          cursor: pointer;
        }
      
       :host([vote=up]) .thumb_up {
         fill: green;
       }
      
       :host([vote=down]) .thumb_down {
         fill: red;
       }
      </style>
      <button class="thumb_down">
        <svg xmlns="http://www.w3.org/2000/svg" height="24" viewbox="0 0 24 24" width="24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
      </button>
      <span class="rating">${this.rating}</span>
      <button class="thumb_up">
        <svg xmlns="http://www.w3.org/2000/svg" height="24" viewbox="0 0 24 24" width="24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
      </button>`;

    render(template, this.shadowRoot);
  }
}

You may also delete your template from index.html. In this render method you define a variable called template and invoke the html tagged template literal function. You will also notice that you have performed a simple data binding inside the span.rating element by using the template literal interpolation syntax of ${...}. This means that you will eventually no longer need to imperatively update that node. Additionally, you call the lit-html render method which synchronously renders the template into the shadow root.

Migrating to Declarative Syntax

Now that you have gotten rid of the <template> element, refactor the code to instead call the newly-defined render method. You can start by leveraging lit-html's event listener binding to clear up the listener code:

index.js

<button
    class="thumb_down"
    @click=${() => {this.vote = 'down'}}>
...
<button
    class="thumb_up"
    @click=${() => {this.vote = 'up'}}>

Lit-html can add an event listener to a node with the @EVENT_NAME binding syntax where, in this case, you update the vote property every time these buttons are clicked.

Next, clean up the initialization code in the constructor and the connectedCallback and disconnectedCallback:

index.js

constructor() {
  super();
  this._rating = 0;
  this._vote = null;
}

connectedCallback() {
  this.attachShadow({mode: 'open'});
  this.render();
}

You were able to remove the click listener logic from all three callbacks and even remove the disconnectedCallback entirely! You were also able to remove all of the DOM initialization code from the connectedCallback making it look much more elegant. This also means that you can get rid of the _onUpClick and _onDownClick listener methods!

Finally, update the property setters to utilize the render method so that the dom can update when either the properties or attributes change:

index.js

set rating(value) {
  this._rating = value;
  this.render();
}

...

set vote(newValue) {
  const oldValue = this._vote;
  if (newValue === oldValue) {
    return;
  }
 
  if (newValue === 'up') {
    if (oldValue === 'down') {
      this.rating += 2;
    } else {
      this.rating += 1;
    }
  } else if (newValue === 'down') {
    if (oldValue === 'up') {
      this.rating -= 2;
    } else {
      this.rating -= 1;
    }
  }
 
  this._vote = newValue;
  this.setAttribute('vote', newValue);
  this.render();
}

Here, you were able to remove the dom update logic from the rating setter and you added a call to render from the vote setter. Now the template is much more readable as you now can see where the bindings and event listeners are applied.

Refresh the page, and you should have a functioning rating button that should look like this when the upvote is pressed!

1609dfc26042b6d3.png

Why LitElement

Some problems are still present with the code. First, if you change the vote property or attribute, it may change the rating property which will result in calling render twice. Despite one of those calls essentially being a no-op and being efficient, the javascript VM is still spending time calling that function twice synchronously. Second, it is tedious adding new properties and attributes as it requires a lot of boilerplate code. This is where LitElement comes in!

LitElement is a base class made by Google for creating fast, lightweight Web Components that can be used across frameworks and environments. Next, take a look at what LitElement can do for us in the rating-element by changing the implementation to use it!

Using LitElement

Start by importing and subclassing the LitElement base class from the lit-element package:

index.js

import {LitElement, html, css} from 'lit-element';

class RatingElement extends LitElement {
...

You import LitElement which is the new base class for the rating-element. Next you import html which lit-element just re-exports from lit-html. And finally, you import css which allows us to define css tagged template literals for css math, templating, and other features under the hood.

Next, move the styles from the render method to LitElement's static stylesheet:

index.js

class RatingElement extends LitElement {
  static get styles() {
    return css`
      :host {
        display: inline-flex;
        align-items: center;
      }
      button {
        background: transparent;
        border: none;
        cursor: pointer;
      }

      :host([vote=up]) .thumb_up {
        fill: green;
      }

      :host([vote=down]) .thumb_down {
        fill: red;
      }
    `;
  }
 ...

This is where most styles live in LitElements. LitElement will take these styles and use features such as Constructable Stylesheets to provide faster rendering times as well as pass it through the Web Components polyfill on older browsers if necessary.

Lifecycle

LitElement introduces a set of render lifecycle callback methods on top of the native Web Component callbacks. These callbacks are triggered when declared LitElement properties are changed.

To use this feature, you must statically declare which properties will trigger the render lifecycle.

index.js

static get properties() {
  return {
    rating: {
      type: Number,
    },
    vote: {
      type: String,
      reflect: true,
    }
  };
 }

Here, you define that rating and vote will trigger the LitElement rendering lifecycle as well as defining the types that will be used to convert the string attributes into properties.

<user-profile .name=${this.user.name} .age=${this.user.age}>
  ${this.user.family.map(member => html`
        <family-member
             .name=${member.name}
             .relation=${member.relation}>
        </family-member>`)}
</user-profile>

Additionally, the reflect flag on the vote property will automatically update the host element's vote attribute that you manually triggered in the vote setter.

Now that you have the static properties block, you can remove all of the attribute and property render updating logic. This means you can remove the following methods:

  • connectedCallback
  • observedAttributes
  • attributeChangedCallback
  • rating (setters and getters)
  • vote (setters and getters but keep the change logic from the setter)

What you keep is the constructor as well as adding a new update lifecycle method:

index.js

constructor() {
  super();
  this.rating = 0;
  this.vote = null;
}

update(changedProps) {
  if (changedProps.has('vote')) {
    const newValue = this.vote;
    const oldValue = changedProps.get('vote');

    if (newValue === 'up') {
      if (oldValue === 'down') {
        this.rating += 2;
      } else {
        this.rating += 1;
      }
    } else if (newValue === 'down') {
      if (oldValue === 'up') {
        this.rating -= 2;
      } else {
        this.rating -= 1;
      }
    }
  }
 
  super.update(changedProps);
}

Here, you simply initialize rating and vote and you move the vote setter logic to the update lifecycle method. The update method is called before render whenever any updating property is changed, because LitElement batches property changes and makes rendering asynchronous. You then call super.update after you make the changes to this.rating otherwise you would trigger an unnecessary render lifecycle.

Finally, render is a LitElement lifecycle method which requires us to return a template:

index.js

render() {
  return html`
    <button
        class="thumb_down"
        @click=${() => {this.vote = 'down'}}>
      <svg xmlns="http://www.w3.org/2000/svg" height="24" viewbox="0 0 24 24" width="24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
    </button>
    <span class="rating">${this.rating}</span>
    <button
        class="thumb_up"
        @click=${() => {this.vote = 'up'}}>
      <svg xmlns="http://www.w3.org/2000/svg" height="24" viewbox="0 0 24 24" width="24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
    </button>`;
}

You no longer have to check for the shadow root, and you no longer have to call the render function previously imported from the lit-html package.

Your element should render in the preview now; give it a click!

Congratulations, you've successfully built a Web Component from scratch and evolved it into a LitElement!

LitElement is super small, super fast, and really fun to code with! You can make components to be consumed by other frameworks, or you can build full-fledged apps with it!

You now know what a Web Component is, how to build one, and how lit-html and LitElement make it easier to build them!

Code Checkpoint

Do you want to check your final code against ours? Remix it here.

What's next?

Check out some of the other CDS 2020 codelabs!

Visit the Lit Room in CDS 2020 and play the Component 64 coding challenges!

Further reading

Community