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:
- Apa itu state dalam konteks Web Components.
- Cara mengelola state internal (lokal) dalam satu komponen.
- Cara komponen berkomunikasi dan berbagi state dalam hubungan parent-child.
- Strategi untuk komunikasi antar komponen yang lebih kompleks.
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:
- State-nya mungkin adalah
count(jumlah hitungan). - Prop-nya mungkin adalah
initial-countataulabel.
Mengelola state dengan baik sangat krusial untuk:
- Responsivitas UI: UI harus diperbarui secara otomatis saat data berubah.
- Maintainability: Kode yang terstruktur dengan baik membuat state lebih mudah dipahami dan dikelola.
- Debugging: Memahami aliran data dan perubahan state sangat membantu saat mencari bug.
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:
- State
_countdisimpan sebagai properti privat. - Setter
set count(value)akan membandingkan nilai baru dengan nilai lama. Jika berbeda, ia akan memperbarui_countdan memanggil_render(). - Metode
_render()bertanggung jawab untuk memperbarui tampilan di Shadow DOM. - 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:
observedAttributesmendaftarkan atributchecked.- Ketika atribut
checkedditambahkan atau dihapus dari elemen di HTML,attributeChangedCallbackakan dipanggil. - Di dalam callback, kita memperbarui state internal
_isCheckeddan memanggil_render(). - Metode
toggleChecked()mengubah atribut, yang secara otomatis memicuattributeChangedCallback.
✅ 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.
-
Melalui Atribut (untuk data sederhana): Sama seperti
attributeChangedCallbackdi atas. Parent mengubah atribut HTML child.// parent-component.js // ... di dalam parent const child = document.createElement('my-child'); child.setAttribute('message', 'Halo dari Parent!'); this.shadowRoot.appendChild(child); -
Melalui Properti JavaScript (untuk data kompleks): Parent mengatur properti JavaScript langsung pada instance child.
// my-child.js class MyChild extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } // Setter untuk properti 'data' set data(value) { this._data = value; this._render(); } get data() { return this._data; } connectedCallback() { this.shadowRoot.innerHTML = `<p></p>`; this._render(); } _render() { if (this.data) { this.shadowRoot.querySelector('p').textContent = `Data dari parent: ${JSON.stringify(this.data)}`; } } } customElements.define('my-child', MyChild); // parent-component.js class ParentComponent extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.message = { text: "Hello", from: "Parent" }; } connectedCallback() { this.shadowRoot.innerHTML = ` <my-child></my-child> <button id="change-data">Ubah Data Child</button> `; const child = this.shadowRoot.querySelector('my-child'); child.data = this.message; // Mengirim objek kompleks this.shadowRoot.getElementById('change-data').addEventListener('click', () => { this.message = { text: "Data Baru", from: "Parent Update" }; child.data = this.message; // Update properti child }); } } customElements.define('parent-component', ParentComponent);💡 Tips: Gunakan properti JavaScript untuk mengirim objek atau array, dan atribut untuk nilai primitif (string, number, boolean).
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:
MyDeleteButtonmendispatchitemDeletedevent denganitemIddidetail.bubbles: truemembuat event “naik” melalui DOM tree.composed: truememungkinkan event melewati Shadow DOM boundary, sehingga bisa didengarkan di luar Shadow DOM.MyItemmendengarkan event ini dan merespons (misalnya, menghapus dirinya sendiri).AppRootjuga bisa mendengarkan event yang sama karena sifatbubblesdancomposed.
🎯 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:
globalEventBusadalah instanceEventTargetyang diimpor oleh kedua komponen.MyThemeSwitchermendispatchthemeChangeevent keglobalEventBus.MyThemedElementmendengarkan event tersebut dan memperbarui tampilannya.- Penting untuk menghapus listener di
disconnectedCallback