Chapter 7: Lit - Web Components for Adults

Standards-Based and Future-Proof

Pop quiz: How many JavaScript frameworks will exist in 10 years?

Trick question. The answer is “who knows, probably 47 new ones.”

Better question: What will browsers support in 10 years?

Web Components. Because they’re a web standard, not a framework.

Lit is a thin layer over Web Components that makes them actually pleasant to use. It’s betting on the platform instead of fighting it.

What Are Web Components, Really?

Web Components are built into browsers. They’re real, native custom elements:

<!-- This is a real HTML element -->
<my-counter></my-counter>

<script>
  class MyCounter extends HTMLElement {
    constructor() {
      super()
      this.count = 0
      this.attachShadow({ mode: 'open' })
      this.shadowRoot.innerHTML = `
        <button>Count: ${this.count}</button>
      `
    }
  }

  customElements.define('my-counter', MyCounter)
</script>

No framework. No compiler. Just the browser.

But vanilla Web Components are verbose and painful. That’s where Lit comes in.

Lit: Web Components Without the Pain

Same component with Lit:

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

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

  static styles = css`
    button {
      padding: 8px 16px;
      background: blue;
      color: white;
      border: none;
      border-radius: 4px;
    }
  `

  render() {
    return html`
      <button @click=${() => this.count++}>
        Count: ${this.count}
      </button>
    `
  }
}

Tagged template literals for HTML. Reactive properties. Scoped styles. Lifecycle methods. All the good parts of frameworks, but producing actual web standards.

No Virtual DOM, Just Efficient Updates

Like Svelte and Solid, Lit doesn’t use a virtual DOM:

@customElement('user-profile')
class UserProfile extends LitElement {
  @property()
  user = null

  render() {
    return html`
      <div>
        <h1>Hello, ${this.user.name}</h1>
        <p>Email: ${this.user.email}</p>
      </div>
    `
  }
}

When this.user.name changes, Lit updates just that text node. No diffing. No reconciliation. Just direct, surgical updates.

The tagged template literal acts like a template. Lit figures out the dynamic parts at compile time and only updates those.

Shadow DOM: CSS Scoping That Actually Works

React’s CSS story:

None of these are standards. All require tools, libraries, or discipline.

Lit uses Shadow DOM:

@customElement('my-card')
class MyCard extends LitElement {
  static styles = css`
    :host {
      display: block;
      border: 1px solid #ccc;
      border-radius: 8px;
      padding: 16px;
    }

    h2 {
      color: blue;
      margin: 0;
    }

    /* These styles CANNOT leak out */
    /* Outside styles CANNOT leak in */
  `

  render() {
    return html`
      <h2><slot name="title"></slot></h2>
      <div><slot></slot></div>
    `
  }
}

That h2 { color: blue } won’t affect any other h2 on the page. And no other styles can affect this component’s h2. True encapsulation. No CSS-in-JS library needed. It’s a browser feature.

Properties and Attributes

Web Components have both properties (JavaScript) and attributes (HTML):

@customElement('user-badge')
class UserBadge extends LitElement {
  @property({ type: String })
  name = 'Guest'

  @property({ type: Boolean })
  admin = false

  render() {
    return html`
      <div class="badge">
        ${this.name}
        ${this.admin ? html`<span class="admin-star">⭐</span>` : ''}
      </div>
    `
  }
}

Usage:

<!-- As attributes (strings) -->
<user-badge name="Alice" admin></user-badge>

<!-- As properties (JavaScript) -->
<script>
  const badge = document.querySelector('user-badge')
  badge.name = 'Bob'
  badge.admin = true
</script>

Lit handles the synchronization automatically.

Events: Real DOM Events

React’s events are synthetic:

<button onClick={handleClick}>Click</button>

Behind the scenes, React creates a synthetic event system because reasons (historical reasons, mostly).

Lit uses real DOM events:

@customElement('my-button')
class MyButton extends LitElement {
  _handleClick() {
    // Dispatch a real DOM event
    this.dispatchEvent(new CustomEvent('my-click', {
      detail: { message: 'Button clicked!' },
      bubbles: true,
      composed: true
    }))
  }

  render() {
    return html`
      <button @click=${this._handleClick}>
        <slot></slot>
      </button>
    `
  }
}

Usage:

<my-button @my-click=${(e) => console.log(e.detail.message)}>
  Click me
</my-button>

<!-- Or with vanilla JS -->
<script>
  document.querySelector('my-button')
    .addEventListener('my-click', (e) => {
      console.log(e.detail.message)
    })
</script>

Real events that work with any framework. Or no framework.

Composition with Slots

React’s composition:

function Card({ title, children }) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="card-body">
        {children}
      </div>
    </div>
  )
}

Lit’s composition uses standard slots:

@customElement('my-card')
class MyCard extends LitElement {
  render() {
    return html`
      <div class="card">
        <h2><slot name="title">Default Title</slot></h2>
        <div class="card-body">
          <slot></slot>
        </div>
      </div>
    `
  }
}

Usage:

<my-card>
  <span slot="title">Custom Title</span>
  <p>This goes in the default slot</p>
  <p>So does this</p>
</my-card>

Named slots, default content, and it’s all a web standard.

Lifecycle

React has useEffect and a million gotchas.

Lit has standard Web Component lifecycle:

@customElement('my-component')
class MyComponent extends LitElement {
  connectedCallback() {
    super.connectedCallback()
    console.log('Component added to DOM')
    // Like componentDidMount
  }

  disconnectedCallback() {
    super.disconnectedCallback()
    console.log('Component removed from DOM')
    // Cleanup subscriptions, timers, etc.
  }

  updated(changedProperties) {
    super.updated(changedProperties)
    if (changedProperties.has('userId')) {
      console.log('userId changed!')
      this.loadUserData()
    }
  }

  firstUpdated() {
    // Called after first render
    // Good for focusing inputs, measuring DOM, etc.
  }
}

Clear, predictable lifecycle hooks. No dependency arrays. No stale closures.

Reactive Controllers: Composition Without Hooks Drama

React hooks are great until they’re not:

// Can't use hooks conditionally
// Must follow Rules of Hooks™
// Dependency arrays everywhere
// Stale closures waiting to bite you

Lit has Reactive Controllers:

class MouseController {
  host

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

  _onMouseMove = (e) => {
    this.x = e.clientX
    this.y = e.clientY
    this.host.requestUpdate()
  }

  hostConnected() {
    window.addEventListener('mousemove', this._onMouseMove)
  }

  hostDisconnected() {
    window.removeEventListener('mousemove', this._onMouseMove)
  }

  x = 0
  y = 0
}

@customElement('mouse-tracker')
class MouseTracker extends LitElement {
  mouse = new MouseController(this)

  render() {
    return html`
      <p>Mouse at: ${this.mouse.x}, ${this.mouse.y}</p>
    `
  }
}

Reusable. Composable. No rules. Works conditionally. Share logic without hooks gymnastics.

Framework Interop: The Secret Weapon

React components only work in React. Vue components only work in Vue. Lit components work everywhere:

<!-- In vanilla HTML -->
<my-button>Click</my-button>

<!-- In React -->
<my-button onClick={handleClick}>Click</my-button>

<!-- In Vue -->
<my-button @click="handleClick">Click</my-button>

<!-- In Angular -->
<my-button (click)="handleClick()">Click</my-button>

<!-- In Svelte -->
<my-button on:click={handleClick}>Click</my-button>

Build once, use anywhere. Because they’re web standards, not framework abstractions.

Migration from React: The Long Game

Migrating to Lit isn’t about replacing React tomorrow. It’s about betting on standards for tomorrow.

Strategy 1: Design System Components

Build your design system in Lit:

// button.js - Works in any framework
@customElement('ds-button')
class DSButton extends LitElement {
  @property({ type: String })
  variant = 'primary'

  @property({ type: Boolean })
  disabled = false

  static styles = css`
    /* Your design system styles */
  `

  render() {
    return html`
      <button
        class=${this.variant}
        ?disabled=${this.disabled}
        @click=${this._handleClick}>
        <slot></slot>
      </button>
    `
  }

  _handleClick() {
    this.dispatchEvent(new Event('click', { bubbles: true }))
  }
}

Use it in your React app:

function App() {
  return (
    <div>
      <ds-button variant="primary" onClick={handleClick}>
        Click me
      </ds-button>
    </div>
  )
}

When you migrate away from React, your design system stays.

Strategy 2: Leaf Components First

Convert simple components to Lit:

Before (React):

function Avatar({ src, name, size = 40 }) {
  return (
    <img
      src={src}
      alt={name}
      width={size}
      height={size}
      className="avatar"
    />
  )
}

After (Lit):

@customElement('ui-avatar')
class Avatar extends LitElement {
  @property() src
  @property() name
  @property({ type: Number }) size = 40

  static styles = css`
    img {
      border-radius: 50%;
      object-fit: cover;
    }
  `

  render() {
    return html`
      <img
        src=${this.src}
        alt=${this.name}
        width=${this.size}
        height=${this.size}>
    `
  }
}

Use in React:

<ui-avatar src="/alice.jpg" name="Alice" size={50} />

Strategy 3: Micro-Frontends

Different parts of your app can be different frameworks:

<!-- Main React app -->
<div id="react-root"></div>

<!-- Lit components mixed in -->
<shopping-cart></shopping-cart>
<user-menu></user-menu>

<!-- Both work together -->

Build Output: Just JavaScript

Lit components compile to plain JavaScript:

// Your component
class MyComponent extends LitElement { /* ... */ }

// Compiles to
class MyComponent extends LitElement { /* ... */ }

Okay, that’s not much of an example. Point is: there’s no magic. No virtual DOM runtime. No framework overhead. Just classes and template literals.

Your bundle includes:

Compare to React:

When Lit Makes Sense

Lit is perfect for:

Lit might not be ideal for:

The Standards Bet

Here’s the thing about frameworks: they come and go.

Remember Backbone? Knockout? Angular.js? Ember?

Web Components are a browser standard. They’re not going anywhere.

React might be dominant today. In 10 years? Who knows.

Lit components you write today will work in 10 years. Because they’re built on the platform, not on a framework that might be legacy tech by then.

That’s a different kind of insurance.

Real Talk: Lit vs React

React: “We’re a library for building user interfaces.”

Lit: “We’re a thin layer over web standards.”

React builds its own world. You buy into the React ecosystem.

Lit builds on the browser. You buy into web standards.

Different philosophies. Different trade-offs.

If you want the safety of standards and the ability to use your components anywhere, Lit is compelling.

If you want the largest ecosystem and the most jobs, React is still there.

Choose based on your priorities.


“I built a design system in React. Then we switched frameworks and rebuilt it. Then we switched again. Now I build in Lit. Problem solved.” — A Developer Who Learned the Hard Way

Up Next: Chapter 8: HTMX - The Intervention You Didn’t Know You Needed