From Web Component to Lit Element

1. Introduction

Last Updated: 2021-08-10

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.

What is Lit

Lit is a simple library for building fast, lightweight web components that work in any framework, or with no framework at all. With Lit you can build shareable components, applications, design systems, and more.

Lit 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
  • What Lit does on top of a web component

What you'll build

  • A vanilla thumbs up / down Web Component
  • A thumbs up / down Lit-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.

2. Getting set up & exploring the Playground

Accessing the code

Throughout the codelab there will be links to the Lit playground like this:

The playground is a code sandbox that runs completely in your browser. It can compile and run TypeScript and JavaScript files, and it can also automatically resolve imports to node modules. e.g.

// before
import './my-file.js';
import 'lit';

// after
import './my-file.js';
import 'https://unpkg.com/lit?module';

You can do the entire tutorial in the Lit playground, using these checkpoints as starting points. If you're using VS Code, you can use these checkpoints to download the starting code for any step, as well as using them to check your work.

Exploring the lit playground UI

The file selector tab bar is labeled Section 1, The code editing section asSection 2, the output preview as Section 3, and the preview reload button asSection 4

The Lit playground UI screenshot highlights sections that you'll use in this codelab.

  1. File selector. Note the plus button...
  2. File editor.
  3. Code preview.
  4. Reload button.
  5. Download button.

VS Code setup (Advanced)

Here are the benefits to using this VS Code setup:

  • Template type checking
  • Template intellisense & autocompletion

If you have NPM, VS Code (with the lit-plugin plugin) installed already and know how to use that environment, you may simply download and start these projects by doing the following:

  • Press the download button
  • Extract the tar file's contents into a directory
  • Install a dev server that can resolve bare module specifiers (the Lit team recommends @web/dev-server)
  • Run the dev server and open your browser (if you are using @web/dev-server you can use npx web-dev-server --node-resolve --watch --open)
    • If you are using the example package.json use npm run serve

3. Define A Custom Element

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.

4. Shadow DOM

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:

The dom tree in the chrome inspector. There is a <rating-element> with a#shadow-root (open) as its child, and the DOM from before inside that shadowroot.

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.

5. HTML Templates

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 its 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.

6. Adding 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!

7. Lit-html

Code Checkpoint

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 are used 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 the rendering system of Lit 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 template which use Tagged Template Literals which are functions that take template strings as arguments with a special syntax. Lit 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 template by adding a render() method to the webcomponent:

index.js

// Dont forget to import from Lit!
import {render, html} from 'lit';

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 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'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 templates 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 event listener 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();
}

// remove disonnectedCallback and _onUpClick and _onDownClick

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);
  // add render method
  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!

Thumb up and down rating slider with a value of 6 and the up thumb coloredgreen

8. LitElement

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 repeat calls of render essentially being a no-op and 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 Lit's base class 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 package:

index.js

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

class RatingElement extends LitElement {
// remove connectedCallback()
...

You import LitElement which is the new base class for the rating-element. Next you keep your html import and finally 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 Lit'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 Lit. Lit will take these styles and use browser 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

Lit introduces a set of render lifecycle callback methods on top of the native Web Component callbacks. These callbacks are triggered when declared Lit 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,
    }
  };
}

// remove observedAttributes() and attributeChangedCallback()
// remove set rating() get rating()

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 willUpdate lifecycle method:

index.js

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

willUpdate(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;
      }
    }
  }
}

// remove set vote() and get vote()

Here, you simply initialize rating and vote and you move the vote setter logic to the willUpdate lifecycle method. The willUpdate method is called before render whenever any updating property is changed, because LitElement batches property changes and makes rendering asynchronous. Changes to reactive properties (like this.rating) in willUpdate will not trigger unnecessary render lifecycle calls.

Finally, render is a LitElement lifecycle method which requires us to return a Lit 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' package.

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

9. Congratulations

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

Lit is super small (< 5kb minified + gzipped), 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 makes it easier to build them!

Code Checkpoint

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

What's next?

Check out some of the other codelabs!

Further reading

Community