Web Development

Web Components dengan Lit

Bangun komponen UI yang reusable dan framework-agnostic dengan Web Components dan Lit β€” Shadow DOM, custom elements, LitElement, reactive properties, dan HTML templates.

1. Pengenalan Web Components

Web Components adalah sekumpulan API browser-native yang memungkinkan Anda membuat custom HTML elements yang reusable, ter-enkapsulasi, dan bisa digunakan di framework apapun (React, Vue, Angular, atau vanilla JS). Web Components terdiri dari tiga teknologi utama:

TeknologiFungsiAPI
Custom ElementsMendefinisikan elemen HTML barucustomElements.define()
Shadow DOMEnkapsulasi style dan markupelement.attachShadow()
HTML TemplatesTemplate yang tidak di-render<template> dan <slot>

Mengapa Web Components?

Diagram: Web Components Ecosystem
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚            WEB COMPONENTS ECOSYSTEM                β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                    β”‚
β”‚  Browser APIs (Native):                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚Custom Elementsβ”‚  β”‚Shadow DOMβ”‚  β”‚HTML Templatesβ”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚         β”‚               β”‚               β”‚         β”‚
β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚
β”‚                         β”‚                          β”‚
β”‚                    β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”                     β”‚
β”‚                    β”‚  Lit   β”‚  ← Library           β”‚
β”‚                    β”‚ (Google)β”‚    untuk memudahkan  β”‚
β”‚                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜    pembuatan WC     β”‚
β”‚                         β”‚                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Bisa digunakan DI MANAPUN:                   β”‚  β”‚
β”‚  β”‚  β€’ React    β€’ Vue    β€’ Angular               β”‚  β”‚
β”‚  β”‚  β€’ Svelte   β€’ Astro  β€’ Vanilla JS             β”‚  β”‚
β”‚  β”‚  β€’ HTML murni ()                β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Custom Elements API

Custom Elements memungkinkan Anda mendefinisikan tag HTML baru dengan JavaScript. Elemen bisa extended dari elemen HTML bawaan atau menjadi elemen baru sepenuhnya.

JavaScript β€” Custom Element Dasar
// Mendefinisikan custom element tanpa library
class MyGreeting extends HTMLElement {
  // Lifecycle: dipanggil saat elemen ditambahkan ke DOM
  connectedCallback() {
    const name = this.getAttribute('name') || 'World';
    this.innerHTML = `<h1>Hello, ${name}!</h1>`;
  }

  // Lifecycle: dipanggil saat elemen dihapus dari DOM
  disconnectedCallback() {
    console.log('Element dihapus dari DOM');
  }

  // Daftar attribute yang diamati perubahannya
  static get observedAttributes() {
    return ['name'];
  }

  // Lifecycle: dipanggil saat attribute berubah
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.innerHTML = `<h1>Hello, ${newValue}!</h1>`;
    }
  }
}

// Registrasi elemen
customElements.define('my-greeting', MyGreeting);

// Penggunaan di HTML:
// <my-greeting name="Budi"></my-greeting>
// Output: <h1>Hello, Budi!</h1>

3. Shadow DOM

Shadow DOM menyediakan enkapsulasi β€” style dan markup di dalam shadow root tidak bisa diakses dari luar, dan sebaliknya. Ini mencegah bentrok CSS dan memastikan komponen benar-benar terisolasi.

JavaScript β€” Shadow DOM
class MyCard extends HTMLElement {
  constructor() {
    super();
    // Attach shadow root (mode: 'open' bisa diakses via JS)
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        /* Style ini TIDAK mempengaruhi elemen di luar */
        :host {
          display: block;
          border: 1px solid #333;
          border-radius: 8px;
          padding: 16px;
          margin: 8px;
          background: #1a1a2e;
          color: white;
        }
        :host(.featured) {
          border-color: gold;
          box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
        }
        :host([dark]) {
          background: #0a0a1a;
        }
        h2 { margin: 0 0 8px; font-size: 1.2rem; }
        p { margin: 0; opacity: 0.8; }
      </style>
      <h2><slot name="title">Default Title</slot></h2>
      <p><slot>Default content</slot></p>
    `;
  }
}

customElements.define('my-card', MyCard);

// Penggunaan:
// <my-card class="featured" dark>
//   <span slot="title">Judul Card</span>
//   Konten card di sini...
// </my-card>

4. Memulai dengan Lit

Lit adalah library dari Google yang menyederhanakan pembuatan Web Components. Lit menambahkan reactive properties, efficient template rendering (hanya update yang berubah), dan decorator-based API yang elegan.

Shell β€” Install Lit
# Install via npm
npm install lit

# Atau CDN (tanpa build step)
# <script type="module">
#   import { LitElement, html, css } from 'https://cdn.jsdelivr.net/gh/nicolo-ribaudo/lit-labs@3.1.0/packages/lit/index.js';
# </script>
TypeScript β€” LitElement Pertama
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('my-counter')
export class MyCounter extends LitElement {
  // Reactive property: perubahan otomatis re-render
  @property({ type: Number })
  count = 0;

  @property({ type: String })
  label = 'Hitung';

  // Styles: di-inject ke Shadow DOM, ter-enkapsulasi
  static styles = css`
    :host {
      display: inline-flex;
      align-items: center;
      gap: 12px;
      font-family: system-ui;
    }
    button {
      width: 36px;
      height: 36px;
      border-radius: 8px;
      border: 1px solid #444;
      background: #222;
      color: white;
      font-size: 1.2rem;
      cursor: pointer;
    }
    button:hover { background: #333; }
    .count {
      font-size: 1.5rem;
      font-weight: bold;
      min-width: 40px;
      text-align: center;
    }
  `;

  // Template: di-render ke Shadow DOM
  render() {
    return html`
      <button @click=${() => this.count--}>βˆ’</button>
      <span class="count">${this.count}</span>
      <button @click=${() => this.count++}>+</button>
      <span>${this.label}</span>
    `;
  }
}

// HTML: <my-counter count="5" label="Klik saya"></my-counter>

5. Reactive Properties

Lit properties secara otomatis re-render komponen saat nilainya berubah. Properties bisa di-set dari attribute HTML atau dari JavaScript.

TypeScript β€” Berbagai Tipe Properties
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

@customElement('user-profile')
export class UserProfile extends LitElement {
  // Public property (bisa di-set dari attribute HTML)
  @property({ type: String }) name = '';
  @property({ type: Number }) age = 0;
  @property({ type: Boolean }) active = false;
  @property({ type: Array }) skills: string[] = [];
  @property({ type: Object }) social: { twitter?: string; github?: string } = {};

  // Internal state (hanya bisa di-set dari dalam komponen)
  @state() private isEditing = false;
  @state() private editName = '';

  static styles = css`
    .profile { padding: 16px; border: 1px solid #333; border-radius: 8px; }
    .active-badge { color: green; }
    .inactive-badge { color: gray; }
    .skills { display: flex; gap: 8px; flex-wrap: wrap; }
    .skill { background: #222; padding: 4px 8px; border-radius: 4px; }
  `;

  render() {
    return html`
      <div class="profile">
        <h2>${this.name}, ${this.age} tahun</h2>
        <span class=${this.active ? 'active-badge' : 'inactive-badge'}>
          ${this.active ? '🟒 Aktif' : 'βšͺ Nonaktif'}
        </span>

        <div class="skills">
          ${this.skills.map((s) => html`<span class="skill">${s}</span>`)}
        </div>

        ${this.social.twitter ? html`<p>Twitter: @${this.social.twitter}</p>` : ''}
        ${this.social.github ? html`<p>GitHub: ${this.social.github}</p>` : ''}
      </div>
    `;
  }
}

// HTML:
// <user-profile
//   name="Budi"
//   age="28"
//   active
//   skills='["JavaScript","TypeScript","Lit"]'
//   .social=${{ twitter: 'budi_dev', github: 'budi123' }}
// ></user-profile>

Property Converter

TypeScript β€” Custom Property Converter
@customElement('temp-converter')
export class TempConverter extends LitElement {
  // Converter untuk menangani JSON dari attribute
  @property({
    type: Object,
    converter: {
      fromAttribute: (value: string | null) => value ? JSON.parse(value) : {},
      toAttribute: (value: object) => JSON.stringify(value),
    },
  })
  config = { unit: 'celsius', precision: 1 };

  @property({ type: Number }) value = 0;

  render() {
    const display = this.config.unit === 'fahrenheit'
      ? (this.value * 9/5 + 32).toFixed(this.config.precision)
      : this.value.toFixed(this.config.precision);
    const unit = this.config.unit === 'fahrenheit' ? 'Β°F' : 'Β°C';

    return html`<span>${display}${unit}</span>`;
  }
}

6. Templates & Directives

Lit menggunakan tagged template literal html`` untuk mendefinisikan template. Lit juga menyediakan directives untuk conditional rendering, loops, dan lain-lain.

TypeScript β€” Lit Directives
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { until } from 'lit/directives/until.js';

interface TodoItem {
  id: number;
  text: string;
  done: boolean;
}

@customElement('todo-app')
export class TodoApp extends LitElement {
  @state() private todos: TodoItem[] = [];
  @state() private newTodo = '';
  @state() private filter: 'all' | 'active' | 'done' = 'all';

  private get filteredTodos() {
    switch (this.filter) {
      case 'active': return this.todos.filter((t) => !t.done);
      case 'done': return this.todos.filter((t) => t.done);
      default: return this.todos;
    }
  }

  static styles = css`
    .todo-item {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 8px;
      border-bottom: 1px solid #222;
    }
    .todo-item.done { text-decoration: line-through; opacity: 0.5; }
    .filters { display: flex; gap: 8px; margin: 12px 0; }
    .filters button.active { background: #0066ff; color: white; }
  `;

  private addTodo() {
    if (!this.newTodo.trim()) return;
    this.todos = [
      ...this.todos,
      { id: Date.now(), text: this.newTodo, done: false },
    ];
    this.newTodo = '';
  }

  render() {
    return html`
      <h2>Todo App</h2>
      <div>
        <input
          .value=${this.newTodo}
          @input=${(e: Event) => this.newTodo = (e.target as HTMLInputElement).value}
          @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.addTodo()}
          placeholder="Tambah todo..."
        />
        <button @click=${this.addTodo}>Tambah</button>
      </div>

      <div class="filters">
        <button class=${classMap({ active: this.filter === 'all' })}
                @click=${() => this.filter = 'all'}>Semua</button>
        <button class=${classMap({ active: this.filter === 'active' })}
                @click=${() => this.filter = 'active'}>Aktif</button>
        <button class=${classMap({ active: this.filter === 'done' })}
                @click=${() => this.filter = 'done'}>Selesai</button>
      </div>

      ${repeat(
        this.filteredTodos,
        (todo) => todo.id,
        (todo) => html`
          <div class=${classMap({ 'todo-item': true, done: todo.done })}>
            <input
              type="checkbox"
              .checked=${todo.done}
              @change=${() => this.toggleTodo(todo.id)}
            />
            <span style=${styleMap({ fontWeight: todo.done ? 'normal' : 'bold' })}>
              ${todo.text}
            </span>
            <button @click=${() => this.removeTodo(todo.id)}>Γ—</button>
          </div>
        `
      )}

      ${this.filteredTodos.length === 0
        ? html`<p>Tidak ada todo.</p>`
        : nothing}
    `;
  }

  private toggleTodo(id: number) {
    this.todos = this.todos.map((t) =>
      t.id === id ? { ...t, done: !t.done } : t
    );
  }

  private removeTodo(id: number) {
    this.todos = this.todos.filter((t) => t.id !== id);
  }
}

7. Events & Lifecycle

TypeScript β€” Custom Events
@customElement('rating-stars')
export class RatingStars extends LitElement {
  @property({ type: Number }) rating = 0;
  @property({ type: Number }) maxStars = 5;
  @property({ type: Boolean, attribute: 'read-only' }) readOnly = false;

  private setRating(value: number) {
    if (this.readOnly) return;

    const oldRating = this.rating;
    this.rating = value;

    // Dispatch custom event
    this.dispatchEvent(new CustomEvent('rating-changed', {
      detail: { rating: value, previousRating: oldRating },
      bubbles: true,     // Event naik ke parent
      composed: true,    // Event melewati shadow boundary
    }));
  }

  render() {
    return html`
      <div class="stars">
        ${Array.from({ length: this.maxStars }, (_, i) => html`
          <span
            class="star ${i < this.rating ? 'filled' : ''}"
            @click=${() => this.setRating(i + 1)}
            style="cursor: ${this.readOnly ? 'default' : 'pointer'}"
          >${i < this.rating ? 'β˜…' : 'β˜†'}</span>
        `)}
      </div>
    `;
  }
}

// Penggunaan:
// <rating-stars
//   max-stars="5"
//   @rating-changed=${(e) => console.log('Rating:', e.detail.rating)}
// ></rating-stars>
TypeScript β€” Lifecycle Hooks
@customElement('lifecycle-demo')
export class LifecycleDemo extends LitElement {
  @property({ type: String }) value = '';

  // 1. Constructor β€” inisialisasi
  constructor() {
    super();
    console.log('1. constructor()');
  }

  // 2. connectedCallback β€” ditambahkan ke DOM
  connectedCallback() {
    super.connectedCallback();
    console.log('2. connectedCallback()');
  }

  // 3. firstUpdated β€” render pertama selesai
  firstUpdated(changedProperties: Map<string, unknown>) {
    console.log('3. firstUpdated()');
    // Aman untuk akses DOM (this.shadowRoot.querySelector, dll)
  }

  // 4. updated β€” setiap render selesai
  updated(changedProperties: Map<string, unknown>) {
    console.log('4. updated()', changedProperties);
    // changedProperties berisi property yang berubah
  }

  // 5. disconnectedCallback β€” dihapus dari DOM
  disconnectedCallback() {
    super.disconnectedCallback();
    console.log('5. disconnectedCallback()');
  }

  render() {
    return html`<p>Value: ${this.value}</p>`;
  }
}

8. Teknik Lanjutan

Menggunakan Lit di React/Vue

JSX β€” Web Components di React
// React bisa langsung menggunakan custom elements!
import React from 'react';

function App() {
  return (
    <div>
      <h1>Aplikasi React dengan Lit Components</h1>

      {/* Menggunakan Web Component langsung di JSX */}
      <my-counter count={5} label="Counter" />

      {/* Menggunakan property (bukan attribute) dengan ref */}
      <user-profile
        name="Budi"
        age={28}
        active
        skills={['React', 'Lit', 'TypeScript']}
      />

      {/* Custom events */}
      <rating-stars
        max-stars={5}
        onRating-changed={(e) => console.log(e.detail.rating)}
      />
    </div>
  );
}

9. Best Practices

βœ… Web Components + Lit Best Practices
  • Kebab-case nama β€” selalu gunakan format my-component
  • Shadow DOM β€” selalu gunakan untuk enkapsulasi style
  • CSS :host β€” gunakan untuk styling komponen itu sendiri
  • Events β€” gunakan bubbles: true, composed: true untuk custom events
  • Avoid tight coupling β€” komunikasi via properties dan events, bukan langsung
  • @state untuk internal, @property untuk public API
  • CSS parts β€” gunakan ::part() untuk allow external styling
  • A11y β€” tambahkan ARIA attributes untuk aksesibilitas
  • Bundle size β€” Lit hanya ~5KB gzipped, sangat efisien
  • Design system β€” Web Components ideal untuk design system lintas-framework

10. Quiz: Uji Pemahamanmu!

Setelah membaca tutorial Web Components dengan Lit, jawablah 5 pertanyaan berikut:

Pertanyaan 1: Tiga teknologi utama Web Components adalah?

a) React, Vue, Angular
b) Custom Elements, Shadow DOM, HTML Templates
c) CSS Grid, Flexbox, Box Model
d) HTTP, WebSocket, SSE

Pertanyaan 2: Apa fungsi Shadow DOM?

a) Mengenkripsi data
b) Mengenkapsulasi style dan markup
c) Menyimpan data di localStorage
d) Mengelola routing

Pertanyaan 3: Apa perbedaan @property dan @state di Lit?

a) Tidak ada perbedaan
b) @property untuk public, @state untuk internal
c) @property untuk string, @state untuk number
d) @property sync, @state async

Pertanyaan 4: Tag HTML apa yang digunakan untuk slot konten?

a) <placeholder>
b) <content>
c) <slot>
d) <insert>

Pertanyaan 5: Web Components bisa digunakan di framework mana?

a) Hanya di React
b) Hanya di Vue
c) Hanya di Angular
d) Semua framework dan vanilla JS
πŸ” Zoom
100%
🎨 Tema