Lit for React Developers

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.

What you'll learn

How to translate several React concepts to Lit such as:

  • JSX & Templating
  • Components & Props
  • State & Lifecycle
  • Hooks
  • Children
  • Refs
  • Mediating State

What you'll build

At the end of this codelab will be able to convert React component concepts to their Lit analogs.

What you'll need

  • The latest version of Chrome, Safari, Firefox, or Edge.
  • Knowledge of HTML, CSS, JavaScript, and Chrome DevTools.
  • Knowledge of React
  • (Advanced) If you want the best development experience download VS Code. You'll also need lit-plugin for VS Code and NPM.

Lit's core concepts and capabilities are similar to React's in many ways, but Lit also has some key differences and differentiators:

It's small

Lit is tiny: it comes down to about 5kb minified and gzipped compared to React + ReactDOM's 40+ kb.

Bar chart of bundle size minified and compressed in kb. Lit bar is 5kb and React + React DOM is 42.2kb

It's fast

In public benchmarks that compare Lit's templating system, lit-html, to React's VDOM, lit-html comes out 8-10% faster than React in the worst case and 50%+ faster in the more common use cases.

LitElement (Lit's component base class) adds a minimal overhead to lit-html, but beats React's performance by 16-30% when comparing component features such as memory usage and interaction and startup times.

grouped bar chart of performance comparing lit to React in milliseconds (lower is better)

Doesn't need a build

With new browser features such as ES modules, and tagged template literals, Lit does not require compilation to run. This means that dev environments can be set up with a script tag + a browser + a server and you're up and running.

With ES modules and modern day CDNs such as Skypack or UNPKG, you may not even need NPM to get started!

Though, if you want to, you can still build and optimize Lit code. The recent developer consolidation around native ES modules has been good for Lit – Lit is just normal JavaScript and there is no need for framework-specific CLIs or build handling.

Framework agnostic

Lit's components build off of a set of web standards called Web Components. This means that building a component in Lit will work in current and future frameworks. If it supports HTML elements, it supports Web Components.

The only issues with framework interop is when the frameworks have restrictive support for the DOM. React is one of these frameworks, but it does allow escape hatches via Refs, and Refs in React are not a good developer experience.

The Lit team has been working on an experimental project called @lit-labs/react which will automatically parse your Lit components and generate a React wrapper so that you do not have to use refs.

Additionally, Custom Elements Everywhere will show you which frameworks and libraries work nicely with custom elements!

First-class TypeScript support

Though it is possible to write all of your Lit code in JavaScript, Lit is written in TypeScript and the Lit team recommends that developers use TypeScript as well!

The Lit team has been working with the Lit community to help maintain projects that bring TypeScript type checking and intellisense to Lit templates at both development and build time with lit-analyzer and lit-plugin.

Screenshot of an IDE showing an improper type check for setting the boolean outlined to a number

Screenshot of an IDE showing intellisense suggestions

Dev tools are built into the browser

Lit components are just HTML elements in the DOM. This means that in order to inspect your components, you do not need to install any tools or exensions for your browser.

You can simply open dev tools, select an element, and explore its properties or state.

image of Chrome dev tools showing $0 returns <mwc-textfield>, $0.value returns hello world, $0.outlined returns true, and {$0} shows property expansion

It's built with server side rendering (SSR) in mind

Lit 2 has been built with SSR support in mind. At the time of writing this codelab, the Lit team has yet to release the SSR tools in a stable form, but the Lit team has already been deploying server side rendered components across Google products and has tested SSR within React applications. The Lit team expects to release these tools externally on GitHub soon.

In the meantime you can follow along with the Lit team's progress here.

It's low buy-in

Lit does not require a significant commitment to use! You can build components in Lit and add them to your existing project. If you don't like them, then you don't have to convert the entire app at once as web components work in other frameworks!

Have you have built an entire app in Lit and want to change to something else? Well, then you can place your current Lit application inside of your new framework and migrate whatever you want to the new framework's components.

Additionally, many modern frameworks support output in web components, so that means that they can typically fit inside a Lit element themselves.

There are two ways to do this codelab:

  • You can do it entirely online, in the browser
  • (Advanced) You can do it on your local machine using VS Code

Accessing the code

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

Code Checkpoint

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://cdn.skypack.dev/lit';

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 as Section 2, the output preview as Section 3, and the preview reload button as Section 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
  • (If TS) set up a quick tsconfig that outputs es modules and es2015+
  • 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 dev

In this section, you will learn the basics of templating in Lit.

JSX & Lit templates

JSX is a syntax extension to JavaScript that allows React users to easily write templates in their JavaScript code. Lit templates serve a similar purpose: expressing a component's UI as a function of its state.

Basic Syntax

Code Checkpoint

In React you would render a JSX hello world like this:

import 'react';
import ReactDOM from 'react-dom';

const name = 'Josh Perez';
const element = (
  <>
    <h1>Hello, {name}</h1>
    <div>How are you?</div>
  </>
);

ReactDOM.render(
  element,
  mountNode
);

In the above example, there are two elements and an included "name" variable. In Lit you would do the following:

import {html, render} from 'lit';

const name = 'Josh Perez';
const element = html`
  <h1>Hello, ${name}</h1>
  <div>How are you?</div>`;

render(
  element,
  mountNode
);

Notice that Lit templates do not need a React Fragment to group multiple elements in its templates.

In Lit, templates are wrapped with an html tagged template LITeral, which happens to be where Lit gets its name!

Template Values

Lit templates can accept other Lit templates, known as a TemplateResult. For example, wrap name in italics (<i>) tags and wrap it with a tagged template literal N.B. Make sure to use backtick character (`) not the single quote character (').

import {html, render} from 'lit';

const name = html`<i>Josh Perez</i>`;
const element = html`
  <h1>Hello, ${name}</h1>
  <div>How are you?</div>`;

render(
  element,
  mountNode
);

Lit TemplateResults can accept arrays, strings, other TemplateResults, as well as directives.

Code Checkpoint

For an exercise, try converting the following React code to Lit:

const itemsToBuy = [
  <li>Bananas</li>,
  <li>oranges</li>,
  <li>apples</li>,
  <li>grapes</li>
];
const element = (
  <>
    <h1>Things to buy:</h1>
    <ol>
      {itemsToBuy}
    </ol>
  </>);

ReactDOM.render(
  element,
  mountNode
);

Answer:

import {html, render} from 'lit';

const itemsToBuy = [
  html`<li>Bananas</li>`,
  html`<li>oranges</li>`,
  html`<li>apples</li>`,
  html`<li>grapes</li>`
];
const element = html`
  <h1>Things to buy:</h1>
  <ol>
    ${itemsToBuy}
  </ol>`;

render(
  element,
  mountNode
);

Passing and setting props

Code checkpoint

One of the biggest differences between JSX and Lit's syntaxes is the data binding syntax. For example, take this React input with bindings:

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element =
  <input
      disabled={disabled}
      className={`static-class ${myClass}`}
      defaultValue={value}/>;

ReactDOM.render(
  element,
  mountNode
);

In the above example, an input is defined which does the following:

  • Sets disabled to a defined variable (false in this case)
  • Sets the class to static-class plus a variable (in this case "static-class my-class")
  • Sets a default value

In Lit you would do the following:

import {html, render} from 'lit';

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element = html`
  <input
      ?disabled=${disabled}
      class="static-class ${myClass}"
      .value=${value}>`;

render(
  element,
  mountNode
);

In the Lit example a boolean binding is added to toggle the disabled attribute.

Next, there is a binding directly to the class attribute rather than className. Multiple bindings can be added to the class attribute, unless you are using the classMap directive which is a declarative helper for toggling classes.

Finally, the value property is set on the input. Unlike in React, this will not set the input element to be read-only as it follows the native implementation and behavior of input.

Lit prop binding syntax

html`<my-element ?attribute-name=${booleanVar}>`;
  • The ? prefix is the binding syntax for toggling an attribute on an element
  • Equivalent to inputRef.toggleAttribute('attribute-name', booleanVar)
  • Useful for elements that use disabled as disabled="false" is still read as true by the DOM because inputElement.hasAttribute('disabled') === true
html`<my-element .property-name=${anyVar}>`;
  • The . prefix is the binding syntax for setting a property of an element
  • Equivalent to inputRef.propertyName = anyVar
  • Good for passing complex data such as objects, arrays, or classes
html`<my-element attribute-name=${stringVar}>`;
  • Binds to an element's attribute
  • Equivalent to inputRef.setAttribute('attribute-name', stringVar)
  • Good for basic values, style rule selectors, and querySelectors

Passing handlers

Code checkpoint

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element =
  <input
      onClick={() => console.log('click')}
      onChange={e => console.log(e.target.value)} />;

ReactDOM.render(
  element,
  mountNode
);

In the above example, an input is defined which does the following:

  • Log the word "click" when the input is clicked
  • Log the value of the input when the user types a character

In Lit you would do the following:

import {html, render} from 'lit';

const disabled = false;
const label = 'my label';
const myClass = 'my-class';
const value = 'my value';
const element = html`
  <input
      @click=${() => console.log('click')}
      @input=${e => console.log(e.target.value)}>`;

render(
  element,
  mountNode
);

In the Lit example, there is a listener added to the click event with @click.

Next, instead of using onChange, there is a binding to <input>'s native input event as the native change event only fires on blur (React abstracts over these events).

Lit event handler syntax

html`<my-element @event-name=${() => {...}}></my-element>`;
  • The @ prefix is the binding syntax for an event listener
  • Equivalent to inputRef.addEventListener('event-name', ...)
  • Uses native DOM event names

In this section you will learn about Lit class components and functions. State and Hooks are covered in more detail in later sections.

Class Components & LitElement

Code checkpoint (TS)Code checkpoint (JS)

The Lit equivalent of a React class component is LitElement, and Lit's concept of "reactive properties" is a combination of React's props and state. For example:

import React from 'react';
import ReactDOM from 'react-dom';

class Welcome extends React.Component {
  constructor(props) {
    super(props);
    this.state = {name: ''};
  }

  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

const element = <Welcome name="Elliott"/>
ReactDOM.render(
  element,
  mountNode
);

In the example above there is a React component that:

  • Renders a name
  • Sets the default value of name to empty string ("")
  • Reassigns name to "Elliott"

This is how you would do this in LitElement

In TypeScript:

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

@customElement('welcome-banner')
class WelcomeBanner extends LitElement {
  @property({type: String})
  name = '';

  render() {
    return html`<h1>Hello, ${this.name}</h1>`
  }
}

In JavaScript:

import {LitElement, html} from 'lit';

class WelcomeBanner extends LitElement {
  static get properties() {
    return {
      name: {type: String}
    }
  }

  constructor() {
    super();
    this.name = '';
  }

  render() {
    return html`<h1>Hello, ${this.name}</h1>`
  }
}

customElements.define('welcome-banner', WelcomeBanner);

And in the HTML file:

<!-- index.html -->
<head>
  <script type="module" src="./index.js"></script>
</head>
<body>
  <welcome-banner name="Elliott"></welcome-banner>
</body>

A review on what is happening in the example above:

@property({type: String})
name = '';
  • Defines a public reactive property – a part of your component's public API
  • Exposes an attribute (by default) as well as a property on your component
  • Defines how to translate the component's attribute (which are strings) into a value
static get properties() {
  return {
    name: {type: String}
  }
}
  • This serves the same function as the @property TS decorator but runs natively in JavaScript
render() {
  return html`<h1>Hello, ${this.name}</h1>`
}
  • This is called whenever any reactive property is changed
@customElement('welcome-banner')
class WelcomeBanner extends LitElement {
  ...
}
  • This associates an HTML Element tag name with a class definition
  • Due to the Custom Elements standard, the tag name must include a hypen (-)
  • this in a LitElement refers to the instance of the custom element (<welcome-banner> in this case)
customElements.define('welcome-banner', WelcomeBanner);
  • This is the JavaScript equivalent of the @customElement TS decorator
<head>
  <script type="module" src="./index.js"></script>
</head>
  • Imports the custom element definition
<body>
  <welcome-banner name="Elliott"></welcome-banner>
</body>
  • Adds the custom element to the page
  • Sets the name property to 'Elliott'

Function Components

Code checkpoint

Lit does not have a 1:1 interpretation of a function component as it does not use JSX or a preprocessor. Though, it is quite simple to compose a function that takes in properties and renders DOM based on those properties. For example:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

const element = <Welcome name="Elliott"/>
ReactDOM.render(
  element,
  mountNode
);

In Lit this would be:

import {html, render} from 'lit';

function Welcome(props) {
  return html`<h1>Hello, ${props.name}</h1>`;
}

render(
  Welcome({name: 'Elliott'}),
  document.body.querySelector('#root')
);

In this section you will learn about Lit's state and lifecycle.

State

Lit's concept of "Reactive Properties" is a mix of React's state and props. Reactive Properties, when changed, can trigger the component lifecycle. Reactive properties come in two variants:

Public reactive properties

// React
import React from 'react';

class MyEl extends React.Component {
  constructor(props) {
    super(props)
    this.state = {name: 'there'}
  }

  componentWillReceiveProps(nextProps) {
    if (this.props.name !== nextProps.name) {
      this.setState({name: nextProps.name})
    }
  }
}

// Lit (TS)
import {LitElement} from 'lit';
import {property} from 'lit/decorators.js';

class MyEl extends LitElement {
  @property() name = 'there';
}
  • Defined by @property
  • Similar to React's props and state but mutable
  • Public API that is accessed and set by consumers of the component

Internal reactive state

// React
import React from 'react';

class MyEl extends React.Component {
  constructor(props) {
    super(props)
    this.state = {name: 'there'}
  }
}

// Lit (TS)
import {LitElement} from 'lit';
import {state} from 'lit/decorators.js';

class MyEl extends LitElement {
  @state() name = 'there';
}
  • Defined by @state
  • Similar to React's state but mutable
  • Private internal state that is typically accessed from within the component or subclasses

Lifecycle

The Lit lifecycle is quite similar to that of React but there are some notable differences.

constructor

// React (js)
import React from 'react';

class MyEl extends React.Component {
  constructor(props) {
    super(props);
    this.state = { counter: 0 };
    this._privateProp = 'private';
  }
}

// Lit (ts)
class MyEl extends LitElement {
  @property({type: Number}) counter = 0;
  private _privateProp = 'private';
}

// Lit (js)
class MyEl extends LitElement {
  static get properties() {
    return { counter: {type: Number} }
  }
  constructor() {
    this.counter = 0;
    this._privateProp = 'private';
  }
}
  • Lit equivalent is also constructor
  • There is no need to pass anything to the super call
  • Invoked by (not totally inclusive):
    • document.createElement
    • document.innerHTML
    • new ComponentClass()
    • If an un-upgraded tag name is on the page and the definition is loaded and registered with @customElement or customElements.define
  • Similar in function to React's constructor

render

// React
render() {
  return <div>Hello World</div>
}

// Lit
render() {
  return html`<div>Hello World</div>`;
}
  • Lit equivalent is also render
  • Can return any renderable result e.g. TemplateResult or string etc.
  • Similar to React, render() should be a pure function
  • Will render to whichever node createRenderRoot() returns (ShadowRoot by default)

componentDidMount

componentDidMount is similar to a combination of both of Lit's firstUpdated and connectedCallback lifecycle callbacks.

firstUpdated

import Chart from 'chart.js';

// React
componentDidMount() {
  this._chart = new Chart(this.chartElRef.current, {...});
}

// Lit
firstUpdated() {
  this._chart = new Chart(this.chartEl, {...});
}
  • Called the first time the component's template is rendered into the component's root
  • Will only be called if the element is connected e.g. not called via document.createElement('my-component') until that node is appended into the DOM tree
  • This is a good place to perform component setup that requires the DOM rendered by the component
  • Unlike React's componentDidMount changes to reactive properties in firstUpdated will cause a re-render, though the browser will typically batch the changes into the same frame. If those changes do not require access to the root's DOM, then they should typically go in willUpdate

connectedCallback

// React
componentDidMount() {
  this.window.addEventListener('resize', this.boundOnResize);
}

// Lit
connectedCallback() {
  super.connectedCallback();
  this.window.addEventListener('resize', this.boundOnResize);
}
  • Called whenever the custom element is inserted into the DOM tree
  • Unlike React components, when custom elements are detached from the DOM, they are not destroyed and thus can be "connected" multiple times
    • firstUpdated will not be called again
  • Useful for re-initializing the DOM or re-attaching event listeners that were cleaned up on disconnect
  • Note: connectedCallback may be called before firstUpdated so on the first call the, DOM may not be available

componentDidUpdate

// React
componentDidUpdate(prevProps) {
  if (this.props.title !== prevProps.title) {
    this._chart.setTitle(this.props.title);
  }
}

// Lit (ts)
updated(prevProps: PropertyValues<this>) {
  if (prevProps.has('title')) {
    this._chart.setTitle(this.title);
  }
}
  • Lit equivalent is updated (using the English past tense of "update")
  • Unlike React, updated is also called on the initial render
  • Similar in function to React's componentDidUpdate

componentWillUnmount

// React
componentWillUnmount() {
  this.window.removeEventListener('resize', this.boundOnResize);
}

// Lit
disconnectedCallback() {
  super.disconnectedCallback();
  this.window.removeEventListener('resize', this.boundOnResize);
}
  • Lit equivalent is similar to disconnectedCallback
  • Unlike React components, when custom elements are detached from the DOM the component is not destroyed
  • Unlike componentWillUnmount, disconnectedCallback is called after the element is removed from the tree
  • DOM inside the root is still attached to the root's subtree
  • Useful for cleaning up event listeners and leaky references so that the browser can garbage collect the component

Exercise

Code Checkpoint (TS)Code Checkpoint (JS)

import React from 'react';
import ReactDOM from 'react-dom';

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

In the above example, there have a simple clock that does the following:

  • It renders "Hello World! It is" and then displays the time
  • Every second it will update the clock
  • When dismounted, it clears the interval calling the tick

First begin with the component class declaration:

// Lit (TS)
// some imports here are imported in advance
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';

@customElement('lit-clock')
class LitClock extends LitElement {
}

// Lit (JS)
// `html` is imported in advance
import {LitElement, html} from 'lit';

class LitClock extends LitElement {
}

customElements.define('lit-clock', LitClock);

Next, initialize date and declare it an internal reactive property with @state since users of the component will not be setting date directly.

// Lit (TS)
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators';

@customElement('lit-clock')
class LitClock extends LitElement {
  @state() // declares internal reactive prop
  private date = new Date(); // initialization
}

// Lit (JS)
import {LitElement, html} from 'lit';

class LitClock extends LitElement {
  static get properties() {
    return {
      // declares internal reactive prop
      date: {state: true}
    }
  }

  constructor() {
    super();
    // initialization
    this.date = new Date();
  }
}

customElements.define('lit-clock', LitClock);

Next, render the template.

// Lit (JS & TS)
render() {
  return html`
    <div>
      <h1>Hello, World!</h1>
      <h2>It is ${this.date.toLocaleTimeString()}.</h2>
    </div>
  `;
}

Now, implement the tick method.

tick() {
  this.date = new Date();
}

Next comes the implementation of componentDidMount. Again, the Lit analog is a mixture of firstUpdated and connectedCallback. In the case of this component, calling tick with setInterval does not require access to the DOM inside the root. Additionally, the interval will get cleared when the element is removed from the document tree, so if it is reattached, the interval would need to start again. Thus, connectedCallback is a better choice here.

// Lit (TS)
@customElement('lit-clock')
class LitClock extends LitElement {
  @state()
  private date = new Date();
  // initialize timerId for TS
  private timerId = -1 as unknown as ReturnType<typeof setTimeout>;

  connectedCallback() {
    super.connectedCallback();
    this.timerId = setInterval(
      () => this.tick(),
      1000
    );
  }

  ...
}

// Lit (JS)
constructor() {
  super();
  // initialization
  this.date = new Date();
  this.timerId = -1; // initialize timerId for JS
}

connectedCallback() {
  super.connectedCallback();
  this.timerId = setInterval(
    () => this.tick(),
    1000
  );
}

Finally, clean up the interval so that it does not execute the tick after the element is disconnected from the document tree.

// Lit (TS & JS)
disconnectedCallback() {
  super.disconnectedCallback();
  clearInterval(this.timerId);
}

Putting it all together, it should look like this:

// Lit (TS)
import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators';

@customElement('lit-clock')
class LitClock extends LitElement {
  @state()
  private date = new Date();
  private timerId = -1 as unknown as ReturnType<typeof setTimeout>;

  connectedCallback() {
    super.connectedCallback();
    this.timerId = setInterval(
      () => this.tick(),
      1000
    );
  }

  tick() {
    this.date = new Date();
  }

  render() {
    return html`
      <div>
        <h1>Hello, World!</h1>
        <h2>It is ${this.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    clearInterval(this.timerId);
  }
}

// Lit (JS)
import {LitElement, html} from 'lit';

class LitClock extends LitElement {
  static get properties() {
    return {
      date: {state: true}
    }
  }

  constructor() {
    super();
    this.date = new Date();
  }

  connectedCallback() {
    super.connectedCallback();
    this.timerId = setInterval(
      () => this.tick(),
      1000
    );
  }

  tick() {
    this.date = new Date();
  }

  render() {
    return html`
      <div>
        <h1>Hello, World!</h1>
        <h2>It is ${this.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    clearInterval(this.timerId);
  }
}

customElements.define('lit-clock', LitClock);

In this section, you will learn how to translate React Hook concepts to Lit.

The concepts of React hooks

React hooks provide a way for function components to "hook" into state. There are several benefits to this.

  • They simplify the reuse of stateful logic
  • Help split up a component into smaller functions

Additionally, the focus on function-based components addressed certain issues with React's class-based syntax such as:

  • Having to pass props from constructor to super
  • The untidy initialization of properties in the constructor
    • This was a reason stated by the React team at the time but solved by ES2019
  • Issues caused by this no longer referring to the component

React hooks concepts in Lit

As mentioned in the Components & Props section, Lit does not offer a way to create custom elements from a function, but LitElement does address most of the main issues with React class components. For example:

// React (at the time of making hooks)
import React from 'react';
import ReactDOM from 'react-dom';

class MyEl extends React.Component {
  constructor(props) {
    super(props); // Leaky implementation
    this.state = {count: 0};
    this._chart = null; // Deemed messy
  }

  render() {
    return (
      <>
        <div>Num times clicked {count}</div>
        <button onClick={this.clickCallback}>click me</button>
      </>
    );
  }

  clickCallback() {
    // Errors because `this` no longer refers to the component
    this.setState({count: this.count + 1});
  }
}

// Lit (ts)
class MyEl extends LitElement {
  @property({type: Number}) count = 0; // No need for constructor to set state
  private _chart = null; // Public class fields introduced to JS in 2019

  render() {
    return html`
        <div>Num times clicked ${count}</div>
        <button @click=${this.clickCallback}>click me</button>`;
  }

  private clickCallback() {
    // No error because `this` refers to component
    this.count++;
  }
}

How does Lit address these issues?

  • constructor takes no arguments
  • All @event bindings auto-bind to this
  • this in the vast majority of the cases refers to the custom element's reference
  • Class properties can now be instantiated as class members. This cleans up constructor-based implementations

Reactive Controllers

Code Checkpoint (TS)Code Checkpoint (JS)

The primary concepts behind Hooks exist in Lit as reactive controllers. Reactive controller patterns allow for sharing stateful logic, splitting up components into smaller, more modular bits, as well as hooking into the update lifecycle of an element.

A reactive controller is an object interface that can hook into the update lifecycle of a controller host like LitElement.

The lifecycle of a ReactiveController and a reactiveControllerHost is:

interface ReactiveController {
  hostConnected(): void;
  hostUpdate(): void;
  hostUpdated(): void;
  hostDisconnected(): void;
}
interface ReactiveControllerHost {
  addController(controller: ReactiveController): void;
  removeController(controller: ReactiveController): void;
  requestUpdate(): void;
  readonly updateComplete: Promise<boolean>;
}

By constructing a reactive controller and attaching it to a host with addController, the controller's lifecycle will be called alongside that of the host. For example, recall the clock example from the State & Lifecycle section:

import React from 'react';
import ReactDOM from 'react-dom';

class Clock extends React.Component {
  constructor(props) {
    super(props);
    this.state = {date: new Date()};
  }

  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000
    );
  }

  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  tick() {
    this.setState({
      date: new Date()
    });
  }

  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    );
  }
}

ReactDOM.render(
  <Clock />,
  document.getElementById('root')
);

In the above example, there is a simple clock that does the following:

  • It renders "Hello World! It is" and then displays the time
  • Every second it will update the clock
  • When dismounted, it clears the interval calling the tick

Building the component scaffolding

First begin with the component class declaration and add the render function.

// Lit (TS) - index.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('my-element')
class MyElement extends LitElement {
  render() {
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${'time to get Lit'}.</h2>
      </div>
    `;
  }
}

// Lit (JS) - index.js
import {LitElement, html} from 'lit';

class MyElement extends LitElement {
  render() {
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${'time to get Lit'}.</h2>
      </div>
    `;
  }
}

customElements.define('my-element', MyElement);

Building the controller

Now switch over to clock.ts and make a class for the ClockController and set up the constructor:

// Lit (TS) - clock.ts
import {ReactiveController, ReactiveControllerHost} from 'lit';

export class ClockController implements ReactiveController {
  private readonly host: ReactiveControllerHost;

  constructor(host: ReactiveControllerHost) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
  }

  private tick() {
  }

  hostDisconnected() {
  }
}

// Lit (JS) - clock.js
export class ClockController {
  constructor(host) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
  }

  tick() {
  }

  hostDisconnected() {
  }
}

A reactive controller can be built any way as long as it shares the ReactiveController interface, but using a class with a constructor that can take in a ReactiveControllerHost interface as well as any other properties needed to initialize the controller is a pattern the Lit team prefers to use for most basic cases.

Now you need to translate the React lifecycle callbacks to controller callbacks. In short:

  • componentDidMount
    • To LitElement's connectedCallback
    • To controller's hostConnected
  • ComponentWillUnmount
    • To LitElement's disconnectedCallback
    • To controller's hostDisconnected

For more information on translating the React lifecycle to the Lit lifecycle, see the State & Lifecycle section.

Next, implement the hostConnected callback and the tick methods, and clean up the interval in hostDisconnected as done in the example in State & Lifecycle section.

// Lit (TS) - clock.ts
export class ClockController implements ReactiveController {
  private readonly host: ReactiveControllerHost;
  private interval = 0 as unknown as ReturnType<typeof setTimeout>;
  date = new Date();

  constructor(host: ReactiveControllerHost) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
    this.interval = setInterval(() => this.tick(), 1000);
  }

  private tick() {
    this.date = new Date();
  }

  hostDisconnected() {
    clearInterval(this.interval);
  }
}

// Lit (JS) - clock.js
export class ClockController {
  interval = 0;
  host;
  date = new Date();

  constructor(host) {
    this.host = host;
    host.addController(this);
  }

  hostConnected() {
    this.interval = setInterval(() => this.tick(), 1000);
  }

  tick() {
    this.date = new Date();
  }

  hostDisconnected() {
    clearInterval(this.interval);
  }
}

Using the controller

To use the clock controller, import the controller, and update the component in index.ts or index.js.

// Lit (TS) - index.ts
import {LitElement, html, ReactiveController, ReactiveControllerHost} from 'lit';
import {customElement} from 'lit/decorators.js';
import {ClockController} from './clock.js';

@customElement('my-element')
class MyElement extends LitElement {
  private readonly clock = new ClockController(this); // Instantiate

  render() {
    // Use controller
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${this.clock.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }
}

// Lit (JS) - index.js
import {LitElement, html} from 'lit';
import {ClockController} from './clock.js';

class MyElement extends LitElement {
  clock = new ClockController(this); // Instantiate

  render() {
    // Use controller
    return html`
      <div>
        <h1>Hello, world!</h1>
        <h2>It is ${this.clock.date.toLocaleTimeString()}.</h2>
      </div>
    `;
  }
}

customElements.define('my-element', MyElement);

To use the controller, you need to instantiate the controller by passing in a reference to the controller host (which is the <my-element> component), and then use the controller in render method.

Triggering re-renders in the controller

Notice that it will show the time, but the time is not updating. This is because the controller is setting date every second, but the host is not updating. This is because the date is changing on the ClockController class and not the component anymore. This means that after the date is set on the controller, the host needs to be told to run its update lifecycle with host.requestUpdate().

// Lit (TS & JS) - clock.ts / clock.js
private tick() {
  this.date = new Date();
  this.host.requestUpdate();
}

Now the clock should be ticking!

For more in-depth comparison of common use cases with hooks, please see the Advanced Topics - Hooks section.

In this section, you will learn how to use slots to manage children in Lit.

Slots & Children

Code Checkpoint (TS)

Slots enable composition by allowing you to nest components.

In React, children are inherited through props. The default slot is props.children and the render function defines where the default slot is positioned. For example:

const MyArticle = (props) => {
 return <article>{props.children}</article>;
};

Keep in mind that props.children are React Components and not HTML elements.

In Lit, children are composed in the render function with slot elements. Notice children are not inherited in the same manner as React. In Lit, children are HTMLElements attached to slots. This attachment is called Projection.

@customElement("my-article")
export class MyArticle extends LitElement {
  render() {
    return html`
      <article>
        <slot></slot>
      </article>
   `;
  }
}

Multiple Slots

Code Checkpoint

In React, adding multiple slots is essentially the same as inheriting more props.

const MyArticle = (props) => {
  return (
    <article>
      <header>
        {props.headerChildren}
      </header>
      <section>
        {props.sectionChildren}
      </section>
    </article>
  );
};

Similarly, adding more <slot> elements creates more slots in Lit. Multiple slots are defined with the name attribute: <slot name="slot-name">. This allows children to declare which slot they will be assigned.

@customElement("my-article")
export class MyArticle extends LitElement {
  render() {
    return html`
      <article>
        <header>
          <slot name="headerChildren"></slot>
        </header>
        <section>
          <slot name="sectionChildren"></slot>
        </section>
      </article>
   `;
  }
}

Default Slot Content

Slots will display their subtree when there are no nodes projected to that slot. When nodes are projected to a slot, that slot will not display its subtree and instead display projected nodes.

@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    return html`
      <section>
        <div>
          <slot name="slotWithDefault">
            <p>
             This message will not be rendered when children are attached to this slot!
            <p>
          </slot>
        </div>
      </section>
   `;
  }
}

Assign children to slots

Code Checkpoint

In React, children are assigned to slots through the properties of a Component. In the example below, React elements are passed to the headerChildren and sectionChildren props.

const MyNewsArticle = () => {
 return (
   <MyArticle
     headerChildren={<h3>Extry, Extry! Read all about it!</h3>}
     sectionChildren={<p>Children are props in React!</p>}
   />
 );
};

In Lit, children are assigned to slots using the slot attribute.

@customElement("my-news-article")
export class MyNewsArticle extends LitElement {
  render() {
    return html`
      <my-article>
        <h3 slot="headerChildren">
          Extry, Extry! Read all about it!
        </h3>
        <p slot="sectionChildren">
          Children are composed with slots in Lit!
        </p>
      </my-article>
   `;
  }
}

If there is no default slot (e.g. <slot>) and there is no slot that has a name attribute (e.g. <slot name="foo">) that matches the slot attribute of the custom element's children (e.g. <div slot="foo">), then that node will not be projected and will not display.

Occasionally, a developer might need to access the API of an HTMLElement.

In this section, you will learn how to acquire element references in Lit.

React References

Code Checkpoint (TS)Code Checkpoint (JS)

A React component is transpiled into a series of function calls that create a virtual DOM when invoked. This virtual DOM is interpreted by ReactDOM and renders HTMLElements.

In React, Refs are space in memory to contain a generated HTMLElement.

const RefsExample = (props) => {
 const inputRef = React.useRef(null);
 const onButtonClick = React.useCallback(() => {
   inputRef.current?.focus();
 }, [inputRef]);

 return (
   <div>
     <input type={"text"} ref={inputRef} />
     <br />
     <button onClick={onButtonClick}>
       Click to focus on the input above!
     </button>
   </div>
 );
};

In the example above, the React component will do the following:

  • Render an empty text input and a button with text
  • Focus the input when the button is clicked

After the initial render, React will set inputRef.current to the generated HTMLInputElement through the ref attribute.

Lit "References" with @query

Lit lives close to the browser and creates a very thin abstraction over native browser features.

The React equivalent to refs in Lit is the HTMLElement returned by the @query and @queryAll decorators.

@customElement("my-element")
export class MyElement extends LitElement {
  @query('input') // Define the query
  inputEl!: HTMLInputElement; // Declare the prop

  // Declare the click event listener
  onButtonClick() {
    // Use the query to focus
    this.inputEl.focus();
  }

  render() {
    return html`
      <input type="text">
      <br />
      <!-- Bind the click listener -->
      <button @click=${this.onButtonClick}>
        Click to focus on the input above!
      </button>
   `;
  }
}

In the example above, the Lit component does the following:

  • Defines a property on MyElement using the @query decorator (creating a getter for an HTMLInputElement).
  • Declares and attaches a click event callback called onButtonClick.
  • Focuses the input on button click

In JavaScript, The @query and @queryAll decorators perform querySelector and querySelectorAll respectively. This is the JavaScript equivalent of @query('input') inputEl!: HTMLInputElement;

get inputEl() {
  return this.renderRoot.querySelector('input');
}

After the Lit component commits the render method's template to my-element's root, the @query decorator will now allow inputEl to return the first input element found in the render root. It will return null if the @query cannot find the specified element.

If there were multiple input elements in the render root, @queryAll would return a list of nodes.

In this section, you will learn how to mediate state between components in Lit.

Reusable Components

Code Checkpoint (TS)

React mimics functional rendering pipelines with top down data flow. Parents provide state to children through props. Children communicate with parents through callbacks found in props.

const CounterButton = (props) => {
  const label = props.step < 0
    ? `- ${-1 * props.step}`
    : `+ ${props.step}`;


  return (
    <button
      onClick={() =>
        props.addToCounter(props.step)}>{label}</button>
  );
};

In the example above, a React component does the following:

  • Creates a label based on the value props.step.
  • Renders a button with +step or -step as its label
  • Updates the parent component by calling props.addToCounter with props.step as an argument on click

Though it is possible to pass callbacks in Lit, the conventional patterns are different. The React Component in the example above could be written as a Lit Component in the example below:

@customElement('counter-button')
export class CounterButton extends LitElement {
  @property({type: Number}) step: number = 0;

  onClick() {
    const event = new CustomEvent('update-counter', {
      bubbles: true,
      detail: {
        step: this.step,
      }
    });

    this.dispatchEvent(event);
  }

  render() {
    const label = this.step < 0
      ? `- ${-1 * this.step}`  // "- 1"
      : `+ ${this.step}`;      // "+ 1"

    return html`
      <button @click=${this.onClick}>${label}</button>
    `;
  }
}

In the example above, a Lit Component will do the following:

  • Create the reactive property step
  • Dispatch a custom event called update-counter carrying the element's step value on click

Browser events bubble up from children to parent elements. Events allow children to broadcast interaction events and state changes. React fundamentally passes state in the opposite direction, so it's uncommon to see React Components dispatch and listen to events in the same manner as Lit Components.

Stateful Components

Code Checkpoint

In React, it's common to use hooks to manage state. A MyCounter Component can be created by reusing the CounterButton Component. Notice how addToCounter is passed to both instances of CounterButton.

const MyCounter = (props) => {
 const [counterSum, setCounterSum] = React.useState(0);
 const addToCounter = useCallback(
   (step) => {
     setCounterSum(counterSum + step);
   },
   [counterSum, setCounterSum]
 );

 return (
   <div>
     <h3>&Sigma;: {counterSum}</h3>
     <CounterButton
       step={-1}
       addToCounter={addToCounter} />
     <CounterButton
       step={1}
       addToCounter={addToCounter} />
   </div>
 );
};

The example above does the following:

  • Creates a count state.
  • Creates a callback that adds a number to a count state.
  • CounterButton uses addToCounter to update count by step on every click.

A similar implementation of MyCounter can be achieved in Lit. Notice how addToCounter is not passed to counter-button. Instead, the callback is bound as an event listener to the @update-counter event on a parent element.

@customElement("my-counter")
export class MyCounter extends LitElement {
  @property({type: Number}) count = 0;

  addToCounter(e: CustomEvent<{step: number}>) {
    // Get step from detail of event or via @query
    this.count += e.detail.step;
  }

  render() {
    return html`
      <div @update-counter="${this.addToCounter}">
        <h3>&Sigma; ${this.count}</h3>
        <counter-button step="-1"></counter-button>
        <counter-button step="1"></counter-button>
      </div>
    `;
  }
}

The example above does the following:

  • Creates a reactive property called count that will update the component when the value is changed
  • Binds the addToCounter callback to the @update-counter event listener
  • Updates count by adding the value found in the detail.step of the update-counter event
  • Sets counter-button's step value via the step attribute

It's more conventional to use reactive properties in Lit to broadcast changes from parents to children. Similarly, it's good practice to use the browser's event system to bubble details from the bottom up.

This approach follows best practices and adheres to Lit's goal of providing cross-platform support for web components.

In this section you will learn about styling in Lit.

Styling

Lit offers multiple ways to style elements as well as a built-in solution.

Inline Styles

Code checkpoint

Lit supports inline styles as well as binding to them.

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

@customElement('my-element')
class MyElement extends LitElement {
  render() {
    return html`
      <div>
    <h1 style="color:orange;">This text is orange</h1>
        <h1 style="color:rebeccapurple;">This text is rebeccapurple</h1>
      </div>
    `;
  }
}

In the above example there are 2 headings each with an inline style.

Now import and bind a the border color from border-color.js to the orange text:

...
import borderColor from './border-color.js';

...

html`
  ...
  <h1 style="color:orange;${borderColor}">This text is orange</h1>
  ...`

Having to calculate the style string every time may be a bit annoying, so Lit offers a directive to help with this.

styleMap

The styleMap directive makes it easier to use JavaScript to set inline styles. For example:

Code Checkpoint (TS)

import {LitElement, html, css} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styleMap} from 'lit/directives/style-map.js';

@customElement('my-element')
class MyElement extends LitElement {
  @property({type: String})
  color = '#000'

  render() {
    // Define the styleMap
    const headerStyle = styleMap({
      'border-color': this.color,
    });

    return html`
      <div>
        <h1
          style="border-style:solid;
          <!-- Use the styleMap -->
          border-width:2px;${headerStyle}">
          This div has a border color of ${this.color}
        </h1>
        <input
          type="color"
          @input=${e => (this.color = e.target.value)}
          value="#000">
      </div>
    `;
  }
}

The above example does the following:

  • Displays an h1 with a border and a color picker
  • Changes the border-color to the value from the color picker

Additionally, there is styleMap which is used to set the styles of the h1. styleMap follows a syntax similar to React's style attribute binding syntax.

CSSResult

Code Checkpoint

The recommended way to style components is to use the css tagged template literal.

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

const ORANGE = css`orange`;

@customElement('my-element')
class MyElement extends LitElement {
  static styles = [
    css`
      #orange {
        color: ${ORANGE};
      }

      #purple {
        color: rebeccapurple;
      }
    `
  ];

  render() {
    return html`
      <div>
    <h1 id="orange">This text is orange</h1>
        <h1 id="purple">This text is rebeccapurple</h1>
      </div>
    `;
  }
}

The above example does the following:

  • Declares a CSS tagged template literal with a binding
  • Sets the colors of two h1s with IDs

Benefits to using the css template tag:

  • Parsed once per class vs per instance
  • Implemented with module reusability in mind
  • Can easily separate styles into their own files
  • Compatible with the CSS Custom Properties polyfill

Additionally, take notice of the <style> tag in index.html:

<!-- index.html -->
<style>
  h1 {
    color: red !important;
  }
</style>

Lit will scope your components' styles to their roots. This means that styles will not leak in and out. To pass styles across to components, the Lit team recommends using CSS Custom Properties as they can penetrate Lit style scoping.

Style Tags

It is also possible to simply inline <style> tags in your templates. The browser will deduplicate these style tags, but by placing them in your templates, they will be parsed per component instance as opposed to per class as is the case with the css tagged template. Additionally, the browser deduplication of CSSResults is much faster.

Using a <link rel="stylesheet"> in your template is also a possiblity for styles, but this is also not recommended because it may cause an initial flash of unstyled content (FOUC).

JSX & Templating

Lit & Virtual DOM

Lit-html does not include a conventional Virtual DOM that diffs each individual node. Instead it utilizes performance features intrinsic to ES2015's tagged template literal spec. Tagged template literals are template literal strings with tag functions attached to them.

Here is an example of a template literal:

const str = 'string';
console.log(`This is a template literal ${str}`);

Here is an example of a tagged template literal:

const tag = (strings, ...values) => ({strings, values});
const f = (x) => tag`hello ${x} how are you`;
console.log(f('world')); // {strings: ["hello ", " how are you"], values: ["world"]}
console.log(f('world').strings === f(1 + 2).strings); // true

In the above example, the tag is the tag function and the f function returns an invocation of a tagged template literal.

A lot of the performance magic in Lit comes from the fact that the string arrays passed into the tag function have the same pointer (as shown in the second console.log). The browser does not recreate a new strings array on each tag function invocation, because it is using the same template literal (i.e. in the same location in the AST). So Lit's binding, parsing, and template caching can take advantage of these features without much runtime diffing overhead.

This built-in browser behavior of tagged template literals gives Lit quite a performance advantage. Most conventional Virtual DOMs do the majority of their work in JavaScript. However, tagged template literals do most of their diffing in the browser's C++.

If you'd like to get started using HTML tagged template literals with React or Preact, the Lit team recommends the htm library.

Though, as is the case with the Google Codelabs site and several online code editors, you will notice that tagged template literal syntax highlighting is not very common. Some IDEs and text editors support them by default such as Atom and GitHub's codeblock highlighter. The Lit team also works very closely with the community to maintain projects such as the lit-plugin which is a VS Code plugin that will add syntax highlighting, type checking, and intellisense to your Lit projects.

Lit & JSX + React DOM

JSX does not run in the browser and instead uses a preprocessor to convert JSX to JavaScript function calls (typically via Babel).

For example, Babel will transform this:

const element = <div className="title">Hello World!</div>;
ReactDOM.render(element, mountNode);

into this:

const element = React.createElement('div', {className: 'title'}, 'Hello World!');
ReactDOM.render(element, mountNode);

React DOM then takes the React output and translates it to actual DOM – properties, attributes, event listeners, and all.

Lit-html uses tagged template literals which can run in the browser without transpilation or a preprocessor. This means that in order to get started with Lit, all you need is an HTML file, an ES module script, and a server. Here's a completely browser-runnable script:

<!DOCTYPE html>
<html>
  <head>
    <script type="module">
      import {html, render} from 'https://cdn.skypack.dev/lit';

      render(
        html`<div>Hello World!</div>`,
        document.querySelector('.root')
      )
    </script>
  </head>
  <body>
    <div class="root"></div>
  </body>
</html>

Additionally, since Lit's templating system, lit-html, does not use a conventional Virtual DOM but rather uses the DOM API directly, Lit 2's size is under 5kb minified and gzipped compared to React (2.8kb) + react-dom's (39.4kb) 40kb minified and gizipped.

Events

React uses a synthetic event system. This means that react-dom must define every event that will be used on every component and provide a camelCase event listener equivalent for each type of node. As a result, JSX does not have a method to define an event listener for a custom event and developers must use a ref and then imperatively apply a listener. This creates a sub-par developer experience when integrating libraries that don't have React in mind thus resulting in having to write a React-specific wrapper.

Lit-html directly accesses the DOM and uses native events, so adding event listeners is as easy as @event-name=${eventNameListener}. This means that less runtime parsing is done for adding event listeners as well as firing events.

Components & Props

React components & custom elements

Under the hood, LitElement uses custom elements to package its components. Custom elements introduce some tradeoffs between React components when it comes to componentization (state and lifecycle is discussed further in the State & Lifecycle section).

Some advantages Custom Elements have as a component system:

  • Native to the browser and do not require any tooling
  • Fit into every browser API from innerHTML and document.createElement to querySelector
  • Can typically be used across frameworks
  • Can be lazily registered with customElements.define and "hydrate" DOM

Some disadvantages Custom Elements have compared to React components:

  • Cannot create a custom element without defining a class (thus no JSX-like functional components)
  • Must contain a closing tag
    • Note: despite the developer convenience browser vendors tend to regret the self-closing tag spec which is why newer specs tend to not include self-closing tags
  • Introduces an extra node to the DOM tree which may cause layout issues
  • Must be registered via JavaScript

Lit has gone with custom elements over a bespoke element system because the custom elements are built into the browser, and the Lit team believes that the cross-framework benefits outweigh the benefits provided by a component abstraction layer. In fact, the Lit team's efforts in the lit-ssr space have overcome the main issues with JavaScript registration. Additionally, some companies such as GitHub take advantage of custom element lazy registration to progressively enhance pages with optional flair.

Passing data to custom elements

A common misconception with custom elements is that data can only be passed in as strings. This misconception likely comes from the fact that element attributes can only be written as strings. Though it is true that Lit will cast string attributes to their defined types, custom elements can also accept complex data as properties.

For example – given the following LitElement definition:

code

// data-test.ts
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('data-test')
class DataTest extends LitElement {
  @property({type: Number})
  num = 0;

  @property({attribute: false})
  data = {a: 0, b: null, c: [html`<div>hello</div>`, html`<div>world</div>`]}

  render() {
    return html`
      <div>num + 1 = ${this.num + 1}</div>
      <div>data.a = ${this.data.a}</div>
      <div>data.b = ${this.data.b}</div>
      <div>data.c = ${this.data.c}</div>`;
  }
}

A primitive reactive property num is defined which will convert an attribute's string value into a number, and then complex data structure is introduced with attribute:false which deactivates Lit's attribute handling.

This is how to pass data to this custom element:

<head>
  <script type="module">
    import './data-test.js'; // loads element definition
    import {html} from './data-test.js';

    const el = document.querySelector('data-test');
    el.data = {
      a: 5,
      b: null,
      c: [html`<div>foo</div>`,html`<div>bar</div>`]
    };
  </script>
</head>
<body>
  <data-test num="5"></data-test>
</body>

State & Lifecycle

Other React Lifecycle Callbacks

static getDerivedStateFromProps

There is no equivalent in Lit as props and state are both the same class properties

shouldComponentUpdate

  • Lit equivalent is shouldUpdate
  • Called on first render unlike React
  • Similar in function to React's shouldComponentUpdate

getSnapshotBeforeUpdate

In Lit, getSnapshotBeforeUpdate is similar to both update and willUpdate

willUpdate

  • Called before update
  • Unlike getSnapshotBeforeUpdate, willUpdate is called before render
  • Changes to reactive properties in willUpdate do not re-trigger the update cycle
  • Good place to compute property values that depend on other properties and are used in the rest of the update process
  • This method is called on the server in SSR, so accessing the DOM is not advised here

update

  • Called after willUpdate
  • Unlike getSnapshotBeforeUpdate, update is called before render
  • Changes to reactive properties in update do not re-trigger the update cycle if changed before calling super.update
  • Good place to capture information from the DOM surrounding the component before the rendered output is committed to the DOM
  • This method is not called on the server in SSR

Other Lit Lifecycle Callbacks

There are several lifecycle callbacks that were not mentioned in the previous section because there is no analog to them in React. They are:

attributeChangedCallback

It is invoked when one of the element's observedAttributes changes. Both observedAttributes and attributeChangedCallback are part of the custom elements spec and implemented by Lit under the hood to provide an attribute API for Lit elements.

adoptedCallback

Invoked when the component is moved to a new document e.g. from an HTMLTemplateElement's documentFragment to the main document. This callback is also a part of the custom elements spec and should only be used for advanced use cases when the component changes documents.

Other lifecycle methods and properties

These methods and properties are class members you can call, override, or await to help manipulate the lifecycle process.

updateComplete

This is a Promise that resolves when the element has finished updating as the update and render lifecycles are asynchronous. An example:

async nextButtonClicked() {
  this.step++;
  // Wait for the next "step" state to render
  await this.updateComplete;
  this.dispatchEvent(new Event('step-rendered'));
}

getUpdateComplete

This is a method that should be overriden to customize when updateComplete resolves. This is common when a component is rendering a child component and their render cycles must be in sync. e.g.

class MyElement extends LitElement {
  ...
  async getUpdateComplete() {
    await super.getUpdateComplete();
    await this.myChild.updateComplete;
  }
}

performUpdate

This method is what calls the update lifecycle callbacks. This should generally not be needed except for rare cases where updating must be done synchronously or for custom scheduling.

hasUpdated

This property is true if the component has updated at least once.

isConnected

A part of the custom elements spec, this property will be true if the element is currently attached to the main document tree.

Lit Update Lifecycle Visualization

There are 3 parts to the update lifecycle:

  • Pre-update
  • Update
  • Post-update

Pre-Update

A directed acyclic graph of nodes with callback names. constructor to requestUpdate. @property to Property Setter. attributeChangedCallback to Property Setter. Property Setter to hasChanged. hasChanged to requestUpdate. requestUpdate points out to the next, update lifecycle graph.

After requestUpdate, a scheduled update is awaited.

Update

A directed acyclic graph of nodes with callback names. Arrow from previous image of pre-update lifecycle points to performUpdate. performUpdate to shouldUpdate. shouldUpdate points to both &lsquo;complete update if false&rsquo; as well as willUpdate. willUpdate to update. update to both render as well as to the next, post-update lifecycle graph. render also points to the next, post-update lifecycle graph.

Post-Update

A directed acyclic graph of nodes with callback names. Arrow from previous image of update lifecycle points to firstUpdated. firstUpdated to updated. updated to updateComplete.

Hooks

Why hooks

Hooks were introduced into React for simple function component use cases that required state. In many simple cases function components with hooks tend to be much simpler and more readable than their class component counterparts. Though, when introducing asynchonous state updates as well as passing data between hooks or effects, the hooks pattern tends to not suffice, and a class-based solution like reactive controllers tend to shine.

API request hooks & controllers

It is common to write a hook that requests data from an API. For example, take this React function component that does the following:

  • index.tsx
    • Renders text
    • Renders useAPI's response
      • User ID + User name
      • Error Message
        • 404 when reaches user 11 (by design)
        • Abort error if API fetch is aborted
      • Loading Message
    • Renders an action button
      • Next user: which fetches the API for the next user
      • Cancel: which aborts the API fetch and displays an error
  • useApi.tsx
    • Defines a useApi custom hook
    • Will async fetch a user object from an api
    • Emits:
      • User name
      • Whether the fetch is loading
      • Any error messages
      • A callback to abort the fetch
    • Aborts fetches in progress if dismounted

Here is the Lit + Reactive Controller implementation.

Takeaways:

  • Reactive Controllers are most like custom hooks
  • Passing non-renderable data between callbacks and effects
    • React uses useRef to pass data betwen useEffect and useCallback
    • Lit uses a private class property
    • React is essentially mimicing the behavior of a private class property

Additionally, if you really like the React function component syntax with hooks but the same buildless environment of Lit, the Lit team highly recommends the Haunted library.

Children

Default Slot

When HTML elements are not given a slot attribute, they are assigned to the default unnamed slot. In the example below, MyApp will slot one paragraph into a named slot. The other paragraph will default to the unnamed slot".

Playground

@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    return html`
      <section>
        <div>
          <slot></slot>
        </div>
        <div>
          <slot name="custom-slot"></slot>
        </div>
      </section>
   `;
  }
}

@customElement("my-app")
export class MyApp extends LitElement {
  render() {
    return html`
      <my-element>
        <p slot="custom-slot">
          This paragraph will be placed in the custom-slot!
        </p>
        <p>
          This paragraph will be placed in the unnamed default slot!
        </p>
      </my-element>
   `;
  }
}

Slot Updates

When the structure of slot descendants change, a slotchange event is fired. A Lit component can bind an event-listener to a slotchange event. In the example below, the first slot found in the shadowRoot will have their assignedNodes logged to the console on slotchange.

@customElement("my-element")
export class MyElement extends LitElement {
  onSlotChange(e: Event) {
    const slot = this.shadowRoot.querySelector('slot');
    console.log(slot.assignedNodes({flatten: true}));
  }

  render() {
    return html`
      <section>
        <div>
          <slot @slotchange="{this.onSlotChange}"></slot>
        </div>
      </section>
   `;
  }
}

Refs

Reference generation

Lit and React both expose a reference to an HTMLElement after their render functions have been called. But it's worth reviewing how React and Lit compose the DOM that is later returned through a Lit @query decorator or a React Reference.

React is a functional pipeline that creates React Components not HTMLElements. Because a Ref is declared before an HTMLElement is rendered, a space in memory is allocated. This is why you see null as the initial value of a Ref, because the actual DOM element hasn't yet been created (or rendered) i.e. useRef(null).

After ReactDOM converts a React Component into an HTMLElement, it looks for an attribute called ref in the ReactComponent. If available, ReactDOM places the HTMLElement's reference to ref.current.

LitElement uses the html template tag function from lit-html to compose a Template Element under the hood. LitElement stamps the template's contents to a custom element's shadow DOM after render. The shadow DOM is a scoped DOM tree encapsulated by a shadow root. The @query decorator then creates a getter for the property which essentially performs a this.shadowRoot.querySelector on the scoped root.

Query Multiple Elements

In the example below, the @queryAll decorator will return the two paragraphs in the shadow root as a NodeList.

@customElement("my-element")
export class MyElement extends LitElement {
  @queryAll('p')
  paragraphs!: NodeList;

  render() {
    return html`
      <p>Hello, world!</p>
      <p>How are you?</p>
   `;
  }
}

Essentially, @queryAll creates a getter for paragraphs that returns the results of this.shadowRoot.querySelectorAll(). In JavaScript, a getter can be declared to perform the same purpose:

get paragraphs() {
  return this.renderRoot.querySelectorAll('p');
}

Query Changing Elements

The @queryAsync decorator is better suited to handle a node that can change based on the state of another element property.

In the example below, @queryAsync will find the first paragraph element. However, a paragraph element will only be rendered when renderParagraph randomly generates an odd number. The @queryAsync directive will return a promise that will resolve when the first paragraph is available.

@customElement("my-dissappearing-paragraph")
export class MyDisapppearingParagraph extends LitElement {
  @queryAsync('p')
  paragraph!: Promise<HTMLElement>;

  renderParagraph() {
    const randomNumber = Math.floor(Math.random() * 10)
    if (randomNumber % 2 === 0) {
      return "";
    }

    return html`<p>This checkbox is checked!`
  }

  render() {
    return html`
      ${this.renderParagraph()}
   `;
  }
}

Mediating State

In React, convention is to use callbacks because state is mediated by React itself. React does it's best to not rely on state provided by elements. The DOM is simply an effect of the rendering process.

External State

It's possible to use Redux, MobX, or any other state management library alongside Lit.

Lit components are created in browser scope. So any library that also exists in browser scope is available to Lit. Many amazing libraries have been built to utilize existing state management systems in Lit.

Here is a series by Vaadin explaining how to leverage Redux in a Lit component.

Take a look at lit-mobx from Adobe to see how a large scale site can leverage MobX in Lit.

Also, check out Apollo Elements to see how developers are including GraphQL in their web components.

Lit works with native browser features and most state management solutions in browser scope can be used in a Lit component.

Styling

Shadow DOM

To natively encapsulate styles and DOM within a Custom Element, Lit uses Shadow DOM. Shadow Roots generate a shadow tree separate from the main document tree. This means that most styles are scoped to this document. Certain styles do leak through such as color, and other font-related styles.

Shadow DOM also introduces new concepts and selectors to the CSS spec:

:host, :host(:hover), :host([hover]) {
  /* Styles the element in which the shadow root is attached to */
}

slot[name="title"]::slotted(*), slot::slotted(:hover), slot::slotted([hover]) {
  /*
   * Styles the elements projected into a slot element. NOTE: the spec only allows
   * styling the direcly slotted elements. Children of those elements are not stylable.
   */
}

Sharing Styles

Lit makes it easy to share styles between components in the form of CSSTemplateResults via css template tags. For example:

// typography.ts
export const body1 = css`
  .body1 {
    ...
  }
`;

// my-el.ts
import {body1} from './typography.ts';

@customElement('my-el')
class MyEl Extends {
  static get styles = [
    body1,
    css`/* local styles come after so they will override bod1 */`
  ]

  render() {
    return html`<div class="body1">...</div>`
  }
}

Theming

Shadow roots present a bit of a challenge to conventional theming which typically are top-down style tag approaches. The conventional way to tackle theming with Web Components that use Shadow DOM is to expose a style API via CSS Custom Properties. For example, this is a pattern that Material Design uses:

.mdc-textfield-outline {
  border-color: var(--mdc-theme-primary, /* default value */ #...);
}
.mdc-textfield--input {
  caret-color: var(--mdc-theme-primary, #...);
}

The user would then change the theme of the site by applying custom property values:

html {
  --mdc-theme-primary: #F00;
}
html[dark] {
  --mdc-theme-primary: #F88;
}

If top-down theming is a must and you are unable to expose styles, it is always possible to disable Shadow DOM by overriding createRenderRoot to return this which will then render your components' template to the custom element itself rather than to a shadow root attached to the custom element. With this you will lose: style encapsulation, DOM encapsulation, and slots.

Production

IE 11

If you need to support older browsers like IE 11, you will have to load some polyfills which come out to about another 33kb. More information can be found here.

Conditional Bundles

The Lit team recommends serving two different bundles, one for IE 11 and one for modern browsers. There are several benefits to this:

  • Serving ES 6 is faster and will serve most of your clients
  • Transpiled ES 5 significantly increases bundle size
  • Conditional bundles give you the best of both worlds
    • IE 11 support
    • No slowdown on modern browsers

More info on how to build a conditionally served bundle can be found on our documentation site here.