State Management Lintas Micro-Frontend: Custom Events vs RxJS vs Shared Store
1. Konteks Industri & Analisis Akar Masalah
Dalam lanskap pengembangan web modern berskala enterprise, arsitektur Micro-Frontend telah menjadi standar de facto untuk memecah monolit raksasa menjadi unit-unit bisnis yang otonom, dapat dideploy secara independen, dan dikelola oleh tim lintas fungsi yang terdistribusi. Namun, di balik keuntungan organisasional ini, tantangan teknis paling pelik muncul pada lapisan State Management Lintas Micro-Frontend. Bagaimana dua atau lebih aplikasi otonom—yang mungkin dibangun menggunakan framework berbeda seperti React, Vue, Svelte, atau bahkan Vanilla JavaScript—dapat berbagi status autentikasi, preferensi pengguna, atau keranjang belanja secara real-time tanpa menciptakan tight coupling (ketergantungan ketat) yang merusak prinsip isolasi mikro-frontend?
Secara historis, banyak arsitek pemula terjebak dalam solusi naif: menggunakan variabel global pada objek window atau memaksakan Global Shared Store monolitik di dalam aplikasi Shell/Host. Pendekatan ini adalah bom waktu arsitektural. Ketika satu modul mengalami kebocoran memori (memory leak), gagal membersihkan event listener, atau memodifikasi state bersama secara asinkron tanpa kontrol, seluruh aplikasi mikro di sekitarnya akan mengalami crash atau state corruption. Lebih buruk lagi, masalah ini sering kali baru terdeteksi di lingkungan produksi dengan beban tinggi (high concurrency), di mana pelacakan sumber mutasi state menjadi sangat sulit dilakukan.
Sebelum penerapan state management terdistribusi yang robust, kita sering melihat kondisi berikut:
- Sebelum: Aplikasi Shell memonopoli seluruh state global. Setiap perubahan state mikro-frontend memerlukan prop-drilling ekstrem melalui parameter iframe atau custom events yang tidak terstandarisasi, mengakibatkan tingginya latensi render, inkonsistensi UI lintas modul, dan kebocoran memori akibat stale event listener yang tidak pernah di-unbind saat unmount.
- Sesudah: Penerapan pola komunikasi asinkron terisolasi menggunakan Custom Events yang diabstraksi melalui Event Bus berbasis RxJS atau BroadcastChannel API. Setiap micro-frontend beroperasi sebagai black box mandiri yang hanya mendengarkan (subscribe) dan memancarkan (publish) sinyal terstruktur, menjamin keamanan memori, otonomi deployment, dan performa UI yang konsisten di bawah beban trafik enterprise.
[!IMPORTANT] Jangan pernah membagikan instance memori objek secara langsung (direct object reference sharing) antar micro-frontend yang berbeda domain atau framework. Selalu gunakan mekanisme serialisasi atau immutable data stream untuk mencegah mutasi state ilegal yang merusak reaktivitas.
2. Konsep Inti & Deep Dive Arsitektur
Untuk merancang sistem state management lintas micro-frontend yang resilien, kita harus memahami tiga pilar mekanisme komunikasi web modern: DOM Custom Events & Dispatcher, BroadcastChannel API untuk komunikasi lintas tab/iframe, dan RxJS (Reactive Extensions for JavaScript) untuk manajemen aliran data asinkron yang kompleks.
Berikut adalah representasi visual dari arsitektur komunikasi terdistribusi yang memisahkan antara Shell Container dan Isolated Micro-Frontends:
+-----------------------------------------------------------------------------------+
| Host / Shell Container |+
| +-----------------------+ +-----------------------+ +-----------------+ ||
| | Micro-Frontend A | | Micro-Frontend B | | Micro-Frontend C| ||
| | (React 18) | | (Vue 3) | | (Svelte 4) | ||
| +-----------+-----------+ +-----------+-----------+ +--------+--------+ ||
| | | | ||
| +-----------------------------+--------------------------+ ||
| | ||
| [ Shared Event Bus ] ||
| (RxJS BehaviorSubject / CustomEvents) ||
+--------------------------------------------+--------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| Cross-Tab / Cross-Origin Layer |
| (BroadcastChannel API / S3/Redis) |
+-----------------------------------------------------------------------------------+
Alur Kerja di Balik Layar (Under the Hood)
- Event Emission: Saat Micro-Frontend A (React) memperbarui data sesi pengguna, ia memicu aksi melalui Event Bus terpusat yang diinisialisasi oleh Shell.
- Serialization & Dispatch: Data dikemas dalam objek terstruktur (Payload) dengan skema tipe yang valid. Event dipancarkan melalui
CustomEventpada objekwindowatau dialirkan melaluiSubjectmilik RxJS. - Reactive Interception: Micro-Frontend B (Vue) dan C (Svelte) yang telah melakukan subscription atau event listener binding menerima payload secara asinkron.
- Local State Reconciliation: Masing-masing micro-frontend memperbarui state lokal mereka secara independen tanpa memengaruhi ruang memori komponen lain.
3. Perbandingan Komparatif / Analisis Pendekatan
Untuk membantu Principal Engineer dalam memilih pendekatan yang tepat sesuai kebutuhan infrastruktur, berikut adalah analisis komparatif mendalam antara tiga pendekatan utama:
| Parameter Perbandingan | Window Custom Events | RxJS Shared Reactive Bus | Shared Store (Global Singleton) |
|---|---|---|---|
| Kompleksitas Implementasi | Rendah (Native Web API) | Menengah-Tinggi (Kurva belajar RxJS) | Tinggi (Butuh manajemen konflik & locking) |
| Memory Footprint (RAM) | Sangat Rendah | Sedang (Tergantung jumlah operators & subscription) | Tinggi (Menyimpan seluruh tree state global) |
| CPU Latency / Overhead | Minimal (Synchronous DOM dispatch) | Sangat Efisien (Asynchronous reactive scheduling) | Tinggi (Potensi re-rendering kaskade lintas modul) |
| Coupling (Ketergantungan) | Longgar (Loose coupling via string event name) | Longgar (Melalui interface type-safe contract) | Ketat (Tight coupling ke skema store bersama) |
| Lintas Tab / Browser Context | Tidak bisa (Hanya dalam satu dokumen DOM) | Bisa (Jika dikombinasikan dengan BroadcastChannel) | Tidak bisa (Kecuali disinkronkan via Storage/WebSocket) |
| Cocok Untuk | Notifikasi sederhana, trigger aksi UI lepas | Aliran data kompleks, debounce, mapping, filtering | Aplikasi monolitik berkedok micro-frontend |
[!TIP] Gunakan Window Custom Events untuk interaksi satu arah yang sederhana (misal: trigger modal login). Gunakan RxJS Shared Bus untuk sinkronisasi state aplikasi yang kompleks, reaktif, dan membutuhkan operator manipulasi data tingkat lanjut.
4. Prasyarat Sistem & Persiapan Lingkungan
Sebelum kita menulis kode implementasi, pastikan lingkungan pengembangan memenuhi standar enterprise berikut:
- Node.js: Versi
18.xatau20.x(LTS). - Package Manager:
pnpm(direkomendasikan untuk manajemen monorepo workspace) ataunpmv9+. - TypeScript: Versi
5.0+untuk memastikan type safety yang ketat pada kontrak payload event. - Browser Support: Mendukung ES2022, CustomEvent API, dan BroadcastChannel API (seluruh browser modern: Chrome 54+, Firefox 38+, Safari 15.4+).
Verifikasi instalasi lingkungan via terminal:
node -v
npm -v
pnpm -v
5. Panduan Implementasi Langkah demi Langkah
Kita akan membangun sistem komunikasi lintas micro-frontend menggunakan RxJS yang di-wrapper dengan TypeScript Type Safety dan memanfaatkan BroadcastChannel untuk sinkronisasi lintas tab browser.
Langkah 1: Membuat Paket Kontrak Event Bersama (shared-events-core)----
Buat file kontrak tipe data untuk memastikan tidak ada pengiriman payload sembarangan antar micro-frontend.
// types/event-bus.types.ts
export enum AppEventTypes {
USER_LOGIN = 'AUTH:USER_LOGIN',
USER_LOGOUT = 'AUTH:USER_LOGOUT',
CART_UPDATED = 'COMMERCE:CART_UPDATED',
THEME_CHANGED = 'UI:THEME_CHANGED'
}
export interface UserLoginPayload {
userId: string;
email: string;
roles: string[];
token: string;
}
export interface CartUpdatedPayload {
itemCount: number;
totalAmount: number;
}
export type EventPayloadMap = {
[AppEventTypes.USER_LOGIN]: UserLoginPayload;
[AppEventTypes.USER_LOGOUT]: void;
[AppEventTypes.CART_UPDATED]: CartUpdatedPayload;
[AppEventTypes.THEME_CHANGED]: { theme: 'light' | 'dark' };
};
Langkah 2: Implementasi Event Bus Kelas Singleton berbasis RxJS & BroadcastChannel----
// services/GlobalEventBus.ts
import { Subject, Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { AppEventTypes, EventPayloadMap } from '../types/event-bus.types';
interface BusMessage<T extends AppEventTypes> {
type: T;
payload: EventPayloadMap[T];
sender: string;
timestamp: number;
}
export class GlobalEventBus {
private static instance: GlobalEventBus;
private eventSubject = new Subject<BusMessage<any>>();
private broadcastChannel: BroadcastChannel;
private instanceId: string;
private constructor(channelName: string = 'EnterpriseMicroFrontendBus') {
this.instanceId = `host_${Math.random().toString(36.substring(2, 9))}`;
this.broadcastChannel = new BroadcastChannel(channelName);
// Tangkap pesan dari tab atau iframe lain
this.broadcastChannel.onmessage = (event: MessageEvent<BusMessage<any>>) => {
// Saring pesan agar tidak memantul kembali ke pengirim asli jika tidak perlu
if (event.data && event.data.sender !== this.instanceId) {
this.eventSubject.next(event.data);
}
};
}
public static getInstance(): GlobalEventBus {
if (!GlobalEventBus.instance) {
GlobalEventBus.instance = new GlobalEventBus();
}
return GlobalEventBus.instance;
}
public publish<T extends AppEventTypes>(type: T, payload: EventPayloadMap[T]): void {
const message: BusMessage<T> = {
type,
payload,
sender: this.instanceId,
timestamp: Date.now()
};
// Broadcast ke internal RxJS stream
this.eventSubject.next(message);
// Broadcast ke luar (tab/iframe lain melalui BroadcastChannel)
this.broadcastChannel.postMessage(message);
}
public subscribe<T extends AppEventTypes>(type: T): Observable<EventPayloadMap[T]> {
return this.eventSubject.asObservable().pipe(
filter((msg): msg is BusMessage<T> => msg.type === type),
map((msg) => msg.payload)
);
}
public destroy(): void {
this.eventSubject.complete();
this.broadcastChannel.close();
}
}
export const eventBus = GlobalEventBus.getInstance();
Langkah 3: Implementasi pada Micro-Frontend A (Penerbit / Publisher)----
// mf-auth/src/LoginComponent.ts
import { eventBus } from 'shared-events-core';
import { AppEventTypes } from 'shared-events-core';
export class LoginComponent {
private handleLoginSuccess() {
const mockUserData = {
userId: 'usr_99281',
email: 'architect@enterprise.io',
roles: ['ADMIN', 'ENGINEER'],
token: 'eyJhbGciOiJIUzI1NiIsIn...'
};
// Publikasikan event login ke seluruh ekosistem
eventBus.publish(AppEventTypes.USER_LOGIN, mockUserData);
console.info('[MF-Auth] User login event published successfully.');
}
}
Langkah 4: Implementasi pada Micro-Frontend B (Konsumen / Subscriber dengan Pembersihan Memori)----
// mf-dashboard/src/DashboardWidget.ts
import { eventBus } from 'shared-events-core';
import { AppEventTypes, UserLoginPayload } from 'shared-events-core';
import { Subscription } from 'rxjs';
export class DashboardWidget {
private authSubscription!: Subscription;
private containerElement: HTMLElement;
constructor(containerId: string) {
const el = document.getElementById(containerId);
if (!el) throw new Error(`Container ${containerId} not found`);
this.containerElement = el;
this.initListeners();
}
private initListeners() {
// Melakukan subscription ke event USER_LOGIN
this.authSubscription = eventBus.subscribe(AppEventTypes.USER_LOGIN).subscribe({
next: (payload: UserLoginPayload) => {
this.renderUserWelcome(payload);
},
error: (err) => console.error('[MF-Dashboard] Error in auth stream:', err)
});
}
private renderUserWelcome(user: UserLoginPayload) {
this.containerElement.innerHTML = `
<div class="welcome-banner">
<h3>Welcome back, ${user.email}</h3>
<p>Roles: ${user.roles.join(', ')}</p>
</div>
`;
}
// PENTING: Wajib dipanggil saat micro-frontend di-unmount dari DOM
public destroy() {
if (this.authSubscription) {
this.authSubscription.unsubscribe();
console.info('[MF-Dashboard] Subscription cleaned up, memory leak prevented.');
}
}
}
6. Bedah Kode & Penjelasan Detail Baris Kunci
Mari kita bedah beberapa baris kode krusial dari implementasi di atas untuk memahami alasan di balik desain tersebut:
-
private instanceId = 'host_' + ...:- Fungsi: Menghasilkan identifikasi unik untuk setiap instance pengirim.
- Dampak jika salah: Jika tidak ada ID pengirim, pesan yang dikirim melalui
BroadcastChannelakan memantul kembali ke tab pengirim dan memicu infinite loop atau eksekusi event ganda yang tidak diinginkan.
-
this.eventSubject.asObservable().pipe(...):- Fungsi: Mengubah
Subjectyang bersifat mutable menjadiObservableread-only bagi komponen konsumen. - Dampak jika salah: Jika komponen konsumen langsung memiliki akses ke
Subject, mereka dapat secara tidak sengaja memanggil.complete()atau memancarkan error palsu yang mematikan aliran event untuk seluruh micro-frontend lainnya.
- Fungsi: Mengubah
-
this.authSubscription.unsubscribe()pada metodedestroy():- Fungsi: Memutus langganan RxJS saat komponen micro-frontend dicopot dari DOM (unmount).
- Dampak jika salah: Tanpa pemanggilan ini, referensi memori komponen akan tetap tersimpan di dalam memori heap JavaScript meskipun elemen HTML-nya sudah dihapus, menyebabkan Memory Leak parah yang menurunkan performa browser secara akumulatif.
7. Skenario Produksi & Studi Kasus Dunia Nyata
Studi Kasus: Sinkronisasi Sesi Autentikasi Global di Platform Finansial High-Concurrency
Di sebuah platform perbankan digital berbasis micro-frontend dengan jutaan pengguna aktif harian, keamanan dan kecepatan sinkronisasi sesi adalah hal mutlak. Ketika token akses kedaluwarsa (expired) di latar belakang karena mekanisme silent token refresh:
- Modul MF-Auth memperbarui token dan memancarkan event
AUTH:TOKEN_REFRESHEDmelaluiGlobalEventBus. - Secara bersamaan, modul MF-Transactions, MF-Portfolio, dan MF-Notifications yang sedang aktif di halaman yang sama menerima payload token baru.
- Modul-modul tersebut memperbarui header HTTP Axios/Fetch instance mereka secara otomatis tanpa memaksa pengguna melakukan hard reload halaman.
- Jika pengguna membuka tab baru,
BroadcastChannelmemastikan bahwa status sesi yang kedaluwarsa atau diperbarui langsung disinkronkan ke tab baru tersebut secara instan tanpa membebani server backend dengan permintaan autentikasi redundan.
[!WARNING] Jangan pernah menyimpan Raw JWT Token atau data sensitif di dalam localStorage tanpa enkripsi tingkat lanjut jika aplikasi rentan terhadap serangan XSS (Cross-Site Scripting). Gunakan HttpOnly Cookies di sisi server untuk perlindungan token maksimal, dan gunakan Event Bus hanya untuk membawa metadata state non-sensitif atau in-memory state references.
8. Common Pitfalls, Anti-Patterns & Solusi
Berikut adalah jebakan umum yang sering dilakukan oleh engineer saat merancang state management lintas micro-frontend:
-
Anti-Pattern 1: Menyimpan Objek Kompleks Tanpa Immutability
- Masalah: Micro-frontend A memodifikasi properti di dalam objek payload yang diterima dari event bus secara langsung (direct mutation).
- Solusi: Selalu gunakan teknik Deep Freeze atau Immutable data structures pada payload event bus untuk menjamin bahwa data yang diterima bersifat read-only bagi setiap penerima.
-
Anti-Pattern 2: Mengabaikan Unsubscription pada Single Page Application (SPA)
- Masalah: Event listener atau RxJS subscription tidak dibersihkan saat pengguna berpindah halaman (route change).
- Solusi: Manfaatkan siklus hidup kerangka kerja (seperti
useEffectcleanup di React,onUnmounteddi Vue, atauonDestroydi Svelte) untuk memanggil metode pembersihan langganan.
-
Anti-Pattern 3: Menggunakan Window Global Variables (
window.myGlobalState)- Masalah: Terjadi konflik penamaan variabel global (namespace collision) antar tim yang berbeda, serta tidak adanya mekanisme reaktif (harus melakukan polling manual).
- Solusi: Ganti total dengan arsitektur Event Bus terenkapsulasi berbasis tipe data yang ketat.
9. Benchmark, Metrik Kinerja & Tuning Performa
Untuk memastikan sistem event bus tidak menjadi bottleneck performa, lakukan pengujian beban menggunakan skrip benchmark sederhana berbasis browser performance API atau tool seperti k6 / Lighthouse.
Metrik Kinerja Utama yang Harus Dipantau:
- Event Dispatch Latency: Waktu yang dibutuhkan dari saat
publish()dipanggil hingga seluruh subscriber menerima payload (Target:< 5msuntuk 10 micro-frontend aktif). - Memory Heap Allocation: Konsumsi memori heap setelah 1.000 kali siklus mount/unmount micro-frontend (Pastikan tidak ada grafik tangga naik yang menandakan memory leak).
Contoh Skrip Pengukuran Latensi Event Bus:
console.time('EventBusLatency');
eventBus.subscribe(AppEventTypes.CART_UPDATED).subscribe(() => {
console.timeEnd('EventBusLatency');
});
eventBus.publish(AppEventTypes.CART_UPDATED, { itemCount: 5, totalAmount: 150000 });
10. Checklist Penerapan Produksi & Kesimpulan
Sebelum merilis arsitektur state management lintas micro-frontend ke lingkungan produksi, pastikan seluruh item dalam checklist berikut telah terpenuhi:
- Seluruh event payload telah didefinisikan menggunakan TypeScript Interfaces/Types yang ketat.
- Mekanisme pembersihan memori (unsubscription/cleanup) telah diterapkan pada setiap komponen micro-frontend.
- Penggunaan
BroadcastChanneltelah diuji untuk skenario multi-tab browser. - Tidak ada akses langsung ke variabel global
windowuntuk mutasi state. - Uji kebocoran memori (Memory Leak Profiling) telah dilakukan menggunakan Chrome DevTools Memory Timeline.
- Penanganan error (Error Boundary / Try-Catch) telah dipasang pada setiap handler subscriber event.
Kesimpulan
Memilih pendekatan state management lintas micro-frontend bukanlah tentang mencari teknologi yang paling canggih, melainkan tentang membangun batasan arsitektur yang jelas (isolation boundaries). Dengan mengombinasikan RxJS untuk aliran data reaktif yang efisien, TypeScript untuk kepastian kontrak data, serta BroadcastChannel API untuk sinkronisasi lintas tab, tim engineering dapat membangun ekosistem web enterprise yang sangat skalabel, aman, dan bebas dari kebocoran memori.