WEB-COMPONENTS STATE-MANAGEMENT JAVASCRIPT VANILLA-JS FRONTEND WEB-DEVELOPMENT UI-UX BEST-PRACTICES COMPONENT-DEVELOPMENT

Mengelola State di Web Components Vanilla: Strategi Praktis untuk Komponen Mandiri dan Interaktif

⏱️ 15 menit baca
👨‍💻

Mengelola State di Web Components Vanilla: Strategi Praktis untuk Komponen Mandiri dan Interaktif

1. Pendahuluan

Di era modern web development, membangun komponen UI yang reusable, interoperable, dan framework-agnostic adalah impian setiap developer. Di sinilah Web Components bersinar! Dengan standar web seperti Custom Elements, Shadow DOM, dan HTML Templates, kita bisa membuat komponen UI yang benar-benar mandiri, bisa dipakai di proyek React, Vue, Angular, atau bahkan di HTML biasa tanpa framework. Keren, kan?

Namun, ada satu tantangan umum yang sering membuat developer bingung saat pertama kali menyelami Web Components vanilla: bagaimana cara mengelola state?

Ketika kita terbiasa dengan framework seperti React (dengan useState atau Redux) atau Vue (dengan data atau Vuex), Web Components vanilla terasa seperti kanvas kosong. Tidak ada sistem reaktivitas bawaan yang langsung memicu re-render saat state berubah. Ini bisa jadi pedang bermata dua: memberikan kita kontrol penuh, tapi juga menuntut kedisiplinan dan pemahaman yang lebih dalam.

Artikel ini akan memandu Anda melalui berbagai strategi praktis untuk mengelola state di Web Components vanilla. Kita akan membahas:

Mari kita mulai petualangan kita dan bangun Web Components yang robust dan interaktif! 🚀

2. Memahami State di Web Components

Sebelum melangkah lebih jauh, mari samakan persepsi tentang “state” dalam konteks Web Components.

📌 State adalah data internal yang dimiliki oleh sebuah komponen, yang seiring waktu bisa berubah, dan perubahan tersebut akan memengaruhi tampilan (rendering) atau perilaku komponen.

Ini berbeda dengan props (atau attributes di Web Components) yang biasanya merupakan data input dari luar komponen. State adalah “memori” komponen itu sendiri.

Misalnya, dalam sebuah komponen tombol CounterButton:

Mengelola state dengan baik sangat krusial untuk:

Web Components vanilla tidak memiliki sistem reaktivitas otomatis seperti framework. Artinya, ketika Anda mengubah properti di dalam kelas komponen, UI tidak akan otomatis diperbarui. Kita perlu “memberi tahu” browser untuk memperbarui tampilan secara manual atau semi-otomatis.

3. Strategi State Lokal: Internal Component State

State lokal adalah state yang hanya relevan dan dikelola di dalam satu instance komponen. Ini adalah fondasi dari setiap komponen interaktif.

3.1. Menggunakan Properti Kelas (Class Properties) dengan Setter

Ini adalah pendekatan paling dasar dan sering digunakan. Anda menyimpan state sebagai properti di dalam kelas Custom Element Anda. Untuk memicu re-render, kita bisa memanfaatkan setter JavaScript.

💡 Konsep: Saat properti state diubah, setter akan menjalankan logika untuk memperbarui Shadow DOM atau elemen internal dan kemudian memicu re-render.

// my-counter.js
class MyCounter extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._count = 0; // State awal
  }

  // Setter untuk properti 'count'
  set count(value) {
    if (value !== this._count) {
      this._count = value;
      this._render(); // Panggil metode render saat state berubah
    }
  }

  get count() {
    return this._count;
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 10px 15px;
          font-size: 16px;
          cursor: pointer;
        }
      </style>
      <button id="increment-btn">Count: ${this.count}</button>
    `;
    this._render(); // Render awal

    this.shadowRoot.getElementById('increment-btn').addEventListener('click', () => {
      this.count++; // Memanggil setter 'count'
    });
  }

  _render() {
    // Memperbarui teks tombol secara langsung
    const button = this.shadowRoot.getElementById('increment-btn');
    if (button) {
      button.textContent = `Count: ${this.count}`;
    }
  }
}

customElements.define('my-counter', MyCounter);

Penjelasan:

  1. State _count disimpan sebagai properti privat.
  2. Setter set count(value) akan membandingkan nilai baru dengan nilai lama. Jika berbeda, ia akan memperbarui _count dan memanggil _render().
  3. Metode _render() bertanggung jawab untuk memperbarui tampilan di Shadow DOM.
  4. Saat tombol diklik, this.count++ memicu setter, yang pada gilirannya memicu re-render.

✅ Keuntungan: Kontrol penuh, sangat eksplisit, cocok untuk komponen yang tidak terlalu kompleks. ❌ Kekurangan: Harus manual memanggil _render() di setiap setter state yang reaktif.

3.2. Menggunakan observedAttributes dan attributeChangedCallback

Untuk state yang perlu direfleksikan sebagai atribut HTML (misalnya, disabled, checked, atau data-status), Anda bisa menggunakan mekanisme bawaan Custom Elements.

💡 Konsep: Browser akan memantau perubahan pada atribut yang Anda daftarkan di observedAttributes. Ketika atribut berubah, attributeChangedCallback akan dipanggil.

// my-toggle-button.js
class MyToggleButton extends HTMLElement {
  static get observedAttributes() {
    return ['checked']; // Kita ingin memantau atribut 'checked'
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._isChecked = false; // State internal
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 8px 12px;
          cursor: pointer;
          border: 1px solid #ccc;
          background-color: #f0f0f0;
        }
        button.checked {
          background-color: #4CAF50;
          color: white;
        }
      </style>
      <button>${this._isChecked ? 'ON' : 'OFF'}</button>
    `;
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.toggleChecked();
    });
    this._render(); // Render awal
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'checked') {
      this._isChecked = newValue !== null; // Atribut ada = true, tidak ada = false
      this._render(); // Render ulang saat atribut berubah
    }
  }

  toggleChecked() {
    if (this.hasAttribute('checked')) {
      this.removeAttribute('checked');
    } else {
      this.setAttribute('checked', '');
    }
  }

  _render() {
    const button = this.shadowRoot.querySelector('button');
    if (button) {
      button.textContent = this._isChecked ? 'ON' : 'OFF';
      button.classList.toggle('checked', this._isChecked);
    }
  }
}

customElements.define('my-toggle-button', MyToggleButton);

Penggunaan di HTML:

<my-toggle-button></my-toggle-button>
<my-toggle-button checked></my-toggle-button>

Penjelasan:

  1. observedAttributes mendaftarkan atribut checked.
  2. Ketika atribut checked ditambahkan atau dihapus dari elemen di HTML, attributeChangedCallback akan dipanggil.
  3. Di dalam callback, kita memperbarui state internal _isChecked dan memanggil _render().
  4. Metode toggleChecked() mengubah atribut, yang secara otomatis memicu attributeChangedCallback.

✅ Keuntungan: State bisa diatur langsung dari markup HTML, mudah diakses oleh tool inspeksi, cocok untuk state boolean atau string sederhana. ⚠️ Kekurangan: Hanya bisa menerima nilai string, tidak cocok untuk objek kompleks. Perubahan atribut selalu memicu callback, bahkan jika nilainya sama (perlu pengecekan oldValue !== newValue).

4. Strategi Komunikasi Antar Komponen: Parent-Child

Komunikasi antar komponen adalah inti dari membangun UI yang kompleks. Pola yang paling umum adalah “Props Down, Events Up”.

4.1. Props Down (Melalui Properti atau Atribut)

Parent mengirim data ke Child.

4.2. Events Up (Melalui Custom Events)

Child memberi tahu Parent (atau komponen lain) tentang sesuatu yang terjadi di dalamnya. Ini adalah cara standar untuk komunikasi dari child ke parent.

💡 Konsep: Child membuat CustomEvent dan mendispatch-nya. Parent (atau siapa pun yang tertarik) mendengarkan event tersebut.

// my-delete-button.js (child component)
class MyDeleteButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        button {
          background-color: #f44336;
          color: white;
          border: none;
          padding: 5px 10px;
          cursor: pointer;
        }
      </style>
      <button>Hapus</button>
    `;
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      // Mendispatch custom event saat tombol dihapus
      this.dispatchEvent(new CustomEvent('itemDeleted', {
        bubbles: true, // Event akan "naik" ke parent
        composed: true, // Event akan melewati Shadow DOM boundary
        detail: { itemId: this.getAttribute('item-id') } // Data yang ingin dikirim
      }));
    });
  }
}
customElements.define('my-delete-button', MyDeleteButton);

// my-item.js (parent component yang berisi delete button)
class MyItem extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const itemId = this.getAttribute('item-id') || 'unknown';
    this.shadowRoot.innerHTML = `
      <style>
        div {
          border: 1px solid #eee;
          padding: 10px;
          margin-bottom: 5px;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
      </style>
      <div>
        <span>Item ID: ${itemId}</span>
        <my-delete-button item-id="${itemId}"></my-delete-button>
      </div>
    `;

    // Mendengarkan custom event dari child
    this.shadowRoot.addEventListener('itemDeleted', (event) => {
      console.log('Item dihapus dari MyItem:', event.detail.itemId);
      // Di sini, MyItem bisa mendispatch event lain ke "grandparent"
      // atau melakukan sesuatu dengan item yang dihapus
      this.remove(); // Hapus item ini dari DOM
    });
  }
}
customElements.define('my-item', MyItem);

// app-root.js (grandparent component)
class AppRoot extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <h1>Daftar Item</h1>
      <my-item item-id="A1"></my-item>
      <my-item item-id="B2"></my-item>
      <my-item item-id="C3"></my-item>
    `;

    // Kita juga bisa mendengarkan event di level yang lebih tinggi
    // (karena event diset `bubbles: true` dan `composed: true`)
    this.shadowRoot.addEventListener('itemDeleted', (event) => {
      console.log('Item dihapus dari AppRoot:', event.detail.itemId);
      // Misalnya, update daftar item di state global
    });
  }
}
customElements.define('app-root', AppRoot);

Penjelasan:

  1. MyDeleteButton mendispatch itemDeleted event dengan itemId di detail.
  2. bubbles: true membuat event “naik” melalui DOM tree.
  3. composed: true memungkinkan event melewati Shadow DOM boundary, sehingga bisa didengarkan di luar Shadow DOM.
  4. MyItem mendengarkan event ini dan merespons (misalnya, menghapus dirinya sendiri).
  5. AppRoot juga bisa mendengarkan event yang sama karena sifat bubbles dan composed.

🎯 Best Practice: Selalu gunakan bubbles: true dan composed: true jika Anda ingin event bisa didengarkan di luar Shadow DOM komponen itu sendiri. Jaga agar detail event tetap sederhana dan informatif.

5. Strategi Komunikasi Antar Komponen: Sibling atau Non-Direct Relasi

Bagaimana jika komponen yang perlu berkomunikasi tidak memiliki hubungan parent-child langsung? Ini sedikit lebih menantang.

5.1. Event Bus (Global Event Listener)

Event bus adalah pola di mana Anda memiliki satu objek global yang berfungsi sebagai pusat untuk mendispatch dan mendengarkan event.

💡 Konsep: Membuat instance EventTarget atau elemen DOM sebagai “bus” global. Komponen publish event ke bus, dan komponen lain subscribe dari bus.

// global-event-bus.js
const globalEventBus = new EventTarget();

// my-theme-switcher.js
class MyThemeSwitcher extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._theme = 'light';
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <button>Toggle Theme: ${this._theme}</button>
    `;
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this._theme = this._theme === 'light' ? 'dark' : 'light';
      this.shadowRoot.querySelector('button').textContent = `Toggle Theme: ${this._theme}`;
      
      // Mendispatch event ke global event bus
      globalEventBus.dispatchEvent(new CustomEvent('themeChange', { detail: { theme: this._theme } }));
    });
  }
}
customElements.define('my-theme-switcher', MyThemeSwitcher);

// my-themed-element.js
class MyThemedElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._currentTheme = 'light';
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          padding: 20px;
          border: 1px solid #ccc;
          margin-top: 10px;
        }
        .light { background-color: white; color: black; }
        .dark { background-color: #333; color: white; }
      </style>
      <div class="${this._currentTheme}">
        Ini adalah elemen yang merespons tema.
      </div>
    `;

    // Mendengarkan event dari global event bus
    globalEventBus.addEventListener('themeChange', this._handleThemeChange);
  }

  disconnectedCallback() {
    // Penting: Hapus listener saat komponen dilepas dari DOM
    globalEventBus.removeEventListener('themeChange', this._handleThemeChange);
  }

  _handleThemeChange = (event) => {
    this._currentTheme = event.detail.theme;
    this.shadowRoot.querySelector('div').className = this._currentTheme;
  }
}
customElements.define('my-themed-element', MyThemedElement);

Penggunaan di HTML:

<my-theme-switcher></my-theme-switcher>
<my-themed-element></my-themed-element>
<my-themed-element></my-themed-element>

Penjelasan:

  1. globalEventBus adalah instance EventTarget yang diimpor oleh kedua komponen.
  2. MyThemeSwitcher mendispatch themeChange event ke globalEventBus.
  3. MyThemedElement mendengarkan event tersebut dan memperbarui tampilannya.
  4. Penting untuk menghapus listener di disconnectedCallback