DEV Community

Jason Butz
Jason Butz

Posted on • Originally published at jasonbutz.info on

Lit - Lighting Fast Web Components

Ever since the first release of web components I've been interested in them. A way to build components that are isolated from each other without needing a bulky library or framework sounded amazing. Unfortunately, they tend to be hard to work with and browser support was slow to come.

Lit is a small library, built on top of web components, that makes it much easier to build interoperable web components. The team released Lit version 3 last year, and I just got around to trying it out. I'm impressed with what Lit is capable of. There are a few things missing that I'd want to see before building out a major application, like a router API, but it seems like a great fit if you need to build some web components to drop into an existing site. It might even be nice for a framework agnostic design system.

Let's take a look at a simple project. In this case I'll make a very simple to-do list application.

Start the Project

Lately, when it comes to starting a new frontend project Vite has been my go-to tool. They have starting templates that fit for most of the libraries I would use, and it "just works".

To start out we need to generate our project, I'm choosing the Lit TypeScript template:

npm create vite@latest my-lit-app -- --template lit-ts
Enter fullscreen mode Exit fullscreen mode

That should give you a directory tree similar to what I have below:

my-lit-app
├── public
│   └── vite.svg
├── src
│   ├── my-element.ts
│   ├── index.css
│   ├── vite-env.d.ts
│   └── assets
│       └── lit.svg
├── .gitignore
├── index.html
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

From there you will need to install your NPM dependencies, then we can start the application to see what we have.

# Install NPM dependencies
npm install
# Start the application in development mode
npm run dev
Enter fullscreen mode Exit fullscreen mode

If you open the URL provided, this is what your page should look like.

Screenshot of the default Vite Lit template page, showing the Vite lightning bolt logo and Lit fire polygon logo above 'Vite + Lit' with a button below that increments a counter when clicked

Building a Component

To keep things simple, let's replace the component the template provided with one of our own. I've replaced the contents of src/my-element.ts with what I have below. This is a very simple boilerplate that will still build and render successfully, serving as a foundation.

import { LitElement, css, html } from 'lit'
import { customElement, property } from 'lit/decorators.js'

@customElement('my-element')
export class MyElement extends LitElement {
  static styles = css``
  render() {
    return html`
      <div></div>
    `
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'my-element': MyElement
  }
}
Enter fullscreen mode Exit fullscreen mode

Expanding from here, I want to get my HTML elements onto the page. After all, that's what is going to let us see progress and start adding functionality. Inside the render method you'll notice a template literal, specifically a tagged template named html, is being used to return the HTML the component will render. I'll add a form with a text input field and a button, along with an empty unordered list element. This special tagged template is part of what Lit provides to make building web components easier. It's important to note, this isn't JSX. It's HTML. You don't have the same restrictions, like needing a single parent element.

// ...
render() {
  return html`
    <form>
      <input type="text" name="todoItem" />
      <button>Add Item</button>
    </form>
    <ul></ul>
  `;
}
// ...
Enter fullscreen mode Exit fullscreen mode

Now, we need a place to put the items for our to-do list and a way to display them on the page. To do that we will use the @state directive. This directive lets us add a private or protected property that still triggers updates to the component. Our array is going to contain objects, so one thing we need to do is help Lit with determining if our value has changed. This is because a new object, even if it has the exact same properties, will not be considered equal when using the === operator. One easy way to make this comparison is to convert our values to JSON strings, and then compare them. That's what I have done below with the hasChanged function.

This code can be added at the top of our class near the style property.

// ...
@state({
  hasChanged: (value?, oldValue?) => {
    return JSON.stringify(value) !== JSON.stringify(oldValue);
  },
})
private todoItems: { id: number; complete: boolean; value: string }[] = [
  {id: 1, complete: false, value: 'Item 1'},
  {id: 2, complete: false, value: 'Item 2'},
];
// ...
Enter fullscreen mode Exit fullscreen mode

Now that we have items for our list, we need to update our HTML to display them. Lit has something to help make sure we keep DOM updates to the minimum, it's called the repeat directive. We pass our array of items, a function that returns an item's id, and a function that renders an item to the directive and it returns the HTML for our list. When our array of items changes, it will help minimize the number of updates. This behavior is something you have to purposefully use, but it's there for the same reason the key attribute is in JSX/React. You may notice the odd ?checked=${...} notation below. That is how Lit handles boolean attributes.

render() {
  return html`
    <form>
      <input type="text" name="todoItem" />
      <button>Add Item</button>
    </form>
    <ul>
      ${repeat(
        this.todoItems,
        (item) => item.id,
        ({ id, complete, value }) =>
          html`<li>
            <input type="checkbox" ?checked=${complete} /> ${value}
          </li>`,
      )}
    </ul>
  `;
}
Enter fullscreen mode Exit fullscreen mode

We can see our list now, but we can't add new items and we aren't saving the checked state of our items.

Screenshot showing a text input field with a button labeled 'Add Item' beside it. Below is a list with items labeled 'Item 1' and 'Item 2' and a checkbox beside each item

Making it Interactive

Let's start with adding new items to our to-do list. First, use the @query decorator to make it easy to access our text input element. We pass the decorator the CSS selector for the element we want to find. Then we are able to access the element through the variable the decorator is applied to. That will make it easy for us to get the input element's value. Next, we'll need to create a private handler method to update our to-do list with the new item. Similar to how you would do it with React, I am defining a new array and assigning it to the variable. To create a unique ID value I am getting the current unix epoch and adding a random number. The epoch number is probably good enough, but a little randomness isn't going to hurt. At the end, after adding the item to the list, I am clearing the value of the text input and setting focus to the input element.
You might notice the e.preventDefault(); line. I'm using the submit event here, and that line will prevent the default behavior when submitting a form. That is, to make an HTTP request to the page defined in the action property, or the current page if that is undefined. We don't need that behavior so do disabling it is the way to do. We could avoid it with a click event, but by using the submit event a user typing something in and pressing enter works exactly as you'd expect with no additional effort.
On the HTML form tag you can see a new attribute that attaches the submit event listener to our form. The @submit syntax is how you attach the listener to a given element. Next, we'll be attaching a click event for our checkboxes.

// ...
@query('input[name="todoItem"]')
private addItemInputEl!: HTMLInputElement;

private _handleAddItem(e: SubmitEvent) {
  e.preventDefault();
  this.todoItems = [
    ...this.todoItems,
    {
      id: Math.floor(Date.now() + Math.random() * 1000),
      complete: false,
      value: this.addItemInputEl.value,
    },
  ];
  this.addItemInputEl.value = '';
  this.addItemInputEl.focus();
}
// ...
render() {
 return html`
   <form @submit=${this._handleAddItem}>
   // ...
 `;
 }
// ...
Enter fullscreen mode Exit fullscreen mode

We'll need to create a private handler method for the click event we'll be generating. We'll take advantage of event bubbling so that clicking on the checkbox and the to-do item itself both cause our checkbox to be checked. To help with identifying the correct item, we'll add a data attribute that contains our item's ID. Our handler needs to determine the item ID to search for and update. The currentTarget property of the event will contain a reference to our list item element, since that is where the event handler is attached. From there, it's a matter of creating a new array with the modified to-do item with the complete value set to the opposite of what it is currently. This ensures we can both check and uncheck an item by clicking on it.

private _handleItemClick(e: PointerEvent) {
  const el: HTMLInputElement = e.currentTarget as HTMLInputElement;
  this.todoItems = [
    ...this.todoItems.map((item) => {
      if (item.id.toString() === el.dataset.id) {
        return {
          ...item,
          complete: !item.complete,
        };
      }
      return item;
    }),
  ];
}

render() {
  return html`
    // ...
        html`<li data-id="${id}" @click=${this._handleItemClick}>
            <input type="checkbox" ?checked=${complete} /> ${value}
          </li>`,
    )}
    // ...
  `;
}
Enter fullscreen mode Exit fullscreen mode

Our to-do list is working now, but it's not obvious you can click on an item to toggle the checkbox. With just a little CSS we can change that. Update the string inside the css tagged template and then your mouse cursor will change to a pointer anytime you are hovering over one of the to-do items.

static styles = css`
  ul li {
    cursor: pointer;
  }
`;
Enter fullscreen mode Exit fullscreen mode

One of the nice things about web components and Lit, is this CSS is scoped to our component. So it won't affect anything else on our page. With a few more CSS changes we can have this to-do list looking even better.

Below is the full component. With some additional CSS changes to the index.css file we can have the application looking even better. Our to-do list disappears if you refresh the page, but I'm not going to go into what it would take to save and restore that from localStorage, that is an exercise I'll leave up to you.

Lit is a fast way to develop web components. This example results in a JS bundle that is about 22kB, which could be smaller but also isn't horrible. Lit has good documentation, and makes it easy to get started. There are still some features I'd like to see, but a lot of them are already in the work. What do you think? Do you like Lit? Do you want me to create the same application in vanilla web components to see what the difference in difficulty and bundle size really is? Let me know on social media.

import { LitElement, css, html } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';

@customElement('my-element')
export class MyElement extends LitElement {
  static styles = css`
    ul {
      list-style: none;
      padding-left: 0;
    }
    ul li {
      cursor: pointer;
    }
    ul li input[type='checkbox'] {
      margin-right: 0.5em;
    }
  `;

  @state({
    hasChanged: (value?, oldValue?) => {
      return JSON.stringify(value) !== JSON.stringify(oldValue);
    },
  })
  private todoItems: { id: number; complete: boolean; value: string }[] = [
    { id: 1, complete: false, value: 'Item 1' },
    { id: 2, complete: false, value: 'Item 2' },
  ];

  @query('input[name="todoItem"]')
  private addItemInputEl!: HTMLInputElement;

  private _handleAddItem(e: SubmitEvent) {
    e.preventDefault();
    this.todoItems = [
      ...this.todoItems,
      {
        id: Math.floor(Date.now() + Math.random() * 1000),
        complete: false,
        value: this.addItemInputEl.value,
      },
    ];
    this.addItemInputEl.value = '';
    this.addItemInputEl.focus();
  }

  private _handleItemClick(e: PointerEvent) {
    const el: HTMLInputElement = e.currentTarget as HTMLInputElement;
    this.todoItems = [
      ...this.todoItems.map((item) => {
        if (item.id.toString() === el.dataset.id) {
          return {
            ...item,
            complete: !item.complete,
          };
        }
        return item;
      }),
    ];
  }

  render() {
    return html`
    <form @submit=${this._handleAddItem}>
      <input type="text" name="todoItem" />
      <button>Add Item</button>
    </form>
    <ul>
      ${repeat(
      this.todoItems,
      (item) => item.id,
      ({ id, complete, value }) =>
        html`<li data-id="${id}" @click=${this._handleItemClick}>
            <input type="checkbox" ?checked=${complete} /> ${value}
          </li>`,
    )}
    </ul>
  `;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'my-element': MyElement
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)