Events #

Event-driven programming adalah paradigma inti di Node.js — hampir semua operasi asinkron di Node.js dibangun di atas sistem event. HTTP server memancarkan event saat request masuk, stream memancarkan event saat data tersedia, proses memancarkan event saat sinyal diterima. Di balik semua ini ada satu kelas: EventEmitter. Memahami EventEmitter secara mendalam membuka pintu untuk membangun komponen yang loosely coupled, observable, dan mudah dikembangkan — karena kamu bisa menambah perilaku baru tanpa mengubah kode yang sudah ada, cukup dengan menambah listener baru.

EventEmitter Dasar #

import { EventEmitter } from "events";

// buat instance EventEmitter
const emitter = new EventEmitter();

// daftarkan listener — fungsi yang dipanggil saat event terjadi
emitter.on("pesan", (teks: string) => {
  console.log("Pesan diterima:", teks);
});

// pancarkan event dengan data
emitter.emit("pesan", "Halo dari EventEmitter!");
// Output: Pesan diterima: Halo dari EventEmitter!

// satu event bisa punya banyak listener
emitter.on("pesan", (teks: string) => {
  console.log("Listener kedua:", teks.toUpperCase());
});

emitter.emit("pesan", "test");
// Output:
// Pesan diterima: test
// Listener kedua: TEST

// emit mengembalikan true jika ada listener, false jika tidak ada
const adaListener = emitter.emit("event-tanpa-listener");
console.log(adaListener); // false

Typed EventEmitter #

Masalah terbesar dengan EventEmitter bawaan adalah tidak ada type safety — kamu bisa emit event dengan nama apapun dan payload apapun tanpa error TypeScript. Ada beberapa pola untuk mengatasinya.

Pola dengan Generic Class #

import { EventEmitter } from "events";

// definisikan map antara nama event dan tipe payload-nya
interface EventMap {
  [event: string | symbol]: unknown[];
}

// typed EventEmitter yang enforce nama event dan tipe payload
class TypedEmitter<T extends EventMap> {
  private emitter = new EventEmitter();

  on<K extends keyof T & (string | symbol)>(
    event: K,
    listener: (...args: T[K] extends unknown[] ? T[K] : never) => void
  ): this {
    this.emitter.on(event as string, listener as (...args: unknown[]) => void);
    return this;
  }

  once<K extends keyof T & (string | symbol)>(
    event: K,
    listener: (...args: T[K] extends unknown[] ? T[K] : never) => void
  ): this {
    this.emitter.once(event as string, listener as (...args: unknown[]) => void);
    return this;
  }

  off<K extends keyof T & (string | symbol)>(
    event: K,
    listener: (...args: T[K] extends unknown[] ? T[K] : never) => void
  ): this {
    this.emitter.off(event as string, listener as (...args: unknown[]) => void);
    return this;
  }

  emit<K extends keyof T & (string | symbol)>(
    event: K,
    ...args: T[K] extends unknown[] ? T[K] : never
  ): boolean {
    return this.emitter.emit(event as string, ...args);
  }

  removeAllListeners(event?: keyof T & (string | symbol)): this {
    this.emitter.removeAllListeners(event as string | undefined);
    return this;
  }
}

// definisikan events untuk domain tertentu
interface PesananEvents extends EventMap {
  "pesanan:dibuat": [pesanan: Pesanan];
  "pesanan:dibayar": [pesananId: string, jumlah: number];
  "pesanan:dikirim": [pesananId: string, noResi: string];
  "pesanan:selesai": [pesananId: string];
  "pesanan:dibatalkan": [pesananId: string, alasan: string];
}

interface Pesanan {
  id: string;
  userId: string;
  items: Array<{ produkId: string; jumlah: number; harga: number }>;
  total: number;
}

// penggunaan — TypeScript akan error jika nama event atau payload salah
const pesananEmitter = new TypedEmitter<PesananEvents>();

pesananEmitter.on("pesanan:dibuat", (pesanan) => {
  // pesanan di-infer sebagai Pesanan — autocomplete bekerja
  console.log(`Pesanan baru: ${pesanan.id}, total: Rp ${pesanan.total}`);
});

pesananEmitter.on("pesanan:dibayar", (pesananId, jumlah) => {
  // pesananId: string, jumlah: number — tipe sudah benar
  console.log(`Pembayaran ${pesananId}: Rp ${jumlah}`);
});

// TypeScript error — "pesanan:tidak-ada" bukan event yang valid
// pesananEmitter.on("pesanan:tidak-ada", () => {}); // ✗ compile error

Pola dengan Subclass EventEmitter #

Untuk komponen yang lebih besar, extend EventEmitter secara langsung:

import { EventEmitter } from "events";

interface KeranjangEvents {
  "item:ditambah": [produkId: string, jumlah: number];
  "item:dihapus": [produkId: string];
  "keranjang:dikosongkan": [];
  "total:berubah": [total: number];
}

// deklarasi untuk menggabungkan tipe event ke class
declare interface Keranjang {
  on<K extends keyof KeranjangEvents>(
    event: K,
    listener: (...args: KeranjangEvents[K]) => void
  ): this;
  emit<K extends keyof KeranjangEvents>(
    event: K,
    ...args: KeranjangEvents[K]
  ): boolean;
}

class Keranjang extends EventEmitter {
  private items = new Map<string, { jumlah: number; harga: number }>();

  tambahItem(produkId: string, jumlah: number, harga: number): void {
    const existing = this.items.get(produkId);
    if (existing) {
      existing.jumlah += jumlah;
    } else {
      this.items.set(produkId, { jumlah, harga });
    }

    this.emit("item:ditambah", produkId, jumlah);
    this.emit("total:berubah", this.hitungTotal());
  }

  hapusItem(produkId: string): void {
    if (this.items.delete(produkId)) {
      this.emit("item:dihapus", produkId);
      this.emit("total:berubah", this.hitungTotal());
    }
  }

  kosongkan(): void {
    this.items.clear();
    this.emit("keranjang:dikosongkan");
    this.emit("total:berubah", 0);
  }

  private hitungTotal(): number {
    let total = 0;
    for (const { jumlah, harga } of this.items.values()) {
      total += jumlah * harga;
    }
    return total;
  }

  get jumlahItem(): number {
    return this.items.size;
  }
}

// penggunaan
const keranjang = new Keranjang();

keranjang.on("item:ditambah", (produkId, jumlah) => {
  console.log(`+ ${jumlah}x ${produkId}`);
});

keranjang.on("total:berubah", (total) => {
  console.log(`Total: Rp ${total.toLocaleString("id-ID")}`);
});

keranjang.tambahItem("laptop-asus", 1, 15_000_000);
keranjang.tambahItem("mouse-logitech", 2, 350_000);
// Output:
// + 1x laptop-asus
// Total: Rp 15.000.000
// + 2x mouse-logitech
// Total: Rp 15.700.000

Metode EventEmitter #

on, once, off #

const emitter = new EventEmitter();

// on — listener permanen, dipanggil setiap kali event dipancarkan
const handler = (data: string) => console.log("on:", data);
emitter.on("data", handler);

// once — listener sekali pakai, otomatis dihapus setelah dipanggil pertama kali
emitter.once("koneksi", () => {
  console.log("Terhubung! (hanya sekali)");
});

emitter.emit("koneksi"); // "Terhubung! (hanya sekali)"
emitter.emit("koneksi"); // tidak ada output — listener sudah dihapus

// off / removeListener — hapus listener tertentu
// PENTING: harus referensi fungsi yang SAMA dengan yang didaftarkan
emitter.off("data", handler);
emitter.emit("data", "test"); // tidak ada output — handler sudah dihapus

// ANTI-PATTERN: tidak bisa hapus arrow function inline
emitter.on("event", (x: number) => console.log(x)); // ✗ tidak bisa dihapus nanti

// BENAR: simpan referensi ke fungsi
const myHandler = (x: number) => console.log(x);
emitter.on("event", myHandler);
emitter.off("event", myHandler); // ✓ bisa dihapus

// removeAllListeners — hapus semua listener untuk satu event atau semua event
emitter.removeAllListeners("data"); // hapus semua listener "data"
emitter.removeAllListeners();       // hapus SEMUA listener dari semua event

Listener Prepend #

// addListener — sama dengan on
emitter.addListener("event", handler);

// prependListener — tambah listener di AWAL antrian (dipanggil pertama)
emitter.on("proses", () => console.log("Listener 1"));
emitter.on("proses", () => console.log("Listener 2"));
emitter.prependListener("proses", () => console.log("Listener 0 (prepend)"));

emitter.emit("proses");
// Output:
// Listener 0 (prepend)
// Listener 1
// Listener 2

// prependOnceListener — prepend + sekali pakai
emitter.prependOnceListener("proses", () => console.log("Sekali di awal"));

Inspeksi Listener #

const emitter = new EventEmitter();

const h1 = () => {};
const h2 = () => {};
emitter.on("event", h1);
emitter.on("event", h2);
emitter.on("lain", h1);

// eventNames — daftar event yang punya listener
console.log(emitter.eventNames()); // ["event", "lain"]

// listenerCount — jumlah listener untuk event tertentu
console.log(emitter.listenerCount("event")); // 2
console.log(emitter.listenerCount("lain"));  // 1

// listeners — array fungsi listener untuk event tertentu
const listeners = emitter.listeners("event");
console.log(listeners.length); // 2
console.log(listeners[0] === h1); // true

// rawListeners — seperti listeners, tapi wrapper once juga dikembalikan
const raw = emitter.rawListeners("event");

Batas Jumlah Listener #

// default maksimal 10 listener per event
// jika melebihi, Node.js menampilkan warning di stderr
const emitter = new EventEmitter();

// ubah batas untuk instance tertentu
emitter.setMaxListeners(20);

// ubah batas global untuk semua EventEmitter baru
EventEmitter.defaultMaxListeners = 20;

// set ke 0 untuk unlimited (hati-hati memory leak!)
emitter.setMaxListeners(0);

// cek batas saat ini
console.log(emitter.getMaxListeners()); // 20

// ANTI-PATTERN: tambah listener dalam loop tanpa cleanup
// ini cepat memenuhi batas dan menyebabkan memory leak
function setupHandlerSalah(emitter: EventEmitter, count: number): void {
  for (let i = 0; i < count; i++) {
    emitter.on("event", () => console.log(i)); // ✗ listener menumpuk
  }
}

// BENAR: satu listener yang menangani logika di dalam
function setupHandlerBenar(emitter: EventEmitter, items: number[]): void {
  emitter.on("event", (data: unknown) => {
    for (const item of items) {
      console.log(item, data); // ✓ satu listener, banyak item
    }
  });
}

Error Event — Penanganan Error di EventEmitter #

Event error memiliki perilaku khusus di Node.js: jika dipancarkan tanpa listener, proses akan crash dengan uncaught exception.

const emitter = new EventEmitter();

// ANTI-PATTERN: emit error tanpa listener
emitter.emit("error", new Error("Sesuatu yang buruk terjadi"));
// ✗ proses crash: Error: Sesuatu yang buruk terjadi

// BENAR: selalu daftarkan listener untuk event "error"
emitter.on("error", (err: Error) => {
  console.error("EventEmitter error:", err.message);
  // tangani error — jangan sampai proses crash
});

emitter.emit("error", new Error("Sesuatu yang buruk terjadi"));
// ✓ Output: EventEmitter error: Sesuatu yang buruk terjadi

// pattern yang baik: class yang extend EventEmitter selalu daftarkan error handler default
class KoneksiDB extends EventEmitter {
  private connected = false;

  constructor() {
    super();

    // error handler default — bisa di-override oleh consumer
    this.on("error", (err: Error) => {
      console.error("[KoneksiDB] Unhandled error:", err.message);
    });
  }

  connect(url: string): void {
    try {
      // simulasi koneksi
      if (!url.startsWith("postgresql://")) {
        throw new Error(`URL tidak valid: ${url}`);
      }
      this.connected = true;
      this.emit("connect");
    } catch (err) {
      this.emit("error", err instanceof Error ? err : new Error(String(err)));
    }
  }

  query(sql: string): void {
    if (!this.connected) {
      this.emit("error", new Error("Belum terhubung ke database"));
      return;
    }
    // eksekusi query...
    this.emit("data", { sql, rows: [] });
  }
}

Event Sekali Pakai dan Promise #

Menggunakan once() bersama Promise sangat berguna untuk menunggu event tertentu sebelum melanjutkan eksekusi.

import { EventEmitter, once } from "events";

// events.once() — helper untuk await satu event
async function tungguEvent(
  emitter: EventEmitter,
  namaEvent: string,
  timeoutMs?: number
): Promise<unknown[]> {
  if (timeoutMs !== undefined) {
    // dengan timeout menggunakan AbortController
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    try {
      const args = await once(emitter, namaEvent, { signal: controller.signal });
      clearTimeout(timer);
      return args;
    } catch (err: any) {
      clearTimeout(timer);
      if (err.name === "AbortError") {
        throw new Error(`Timeout menunggu event "${namaEvent}"`);
      }
      throw err;
    }
  }

  return once(emitter, namaEvent);
}

// contoh: tunggu server siap sebelum melanjutkan
class Server extends EventEmitter {
  async start(port: number): Promise<void> {
    setTimeout(() => {
      this.emit("ready", port);
    }, 100); // simulasi startup
  }
}

const server = new Server();
server.start(3000);

const [port] = await tungguEvent(server, "ready", 5000);
console.log(`Server siap di port ${port}`);

// pola async iterator untuk stream event
import { on } from "events";

async function prosesEventStream(emitter: EventEmitter): Promise<void> {
  // on() dari 'events' membuat async iterator dari event
  for await (const [data] of on(emitter, "data")) {
    console.log("Data diterima:", data);
    // break untuk menghentikan iterasi
    if (data === null) break;
  }
}

Pattern Observer dengan EventEmitter #

EventEmitter adalah implementasi natural dari pola Observer — objek (observable) memiliki daftar dependents (observer) yang dinotifikasi saat state berubah.

flowchart LR
    A[Observable\nEventEmitter] -- emit event --> B[Observer 1\nListener]
    A -- emit event --> C[Observer 2\nListener]
    A -- emit event --> D[Observer 3\nListener]
    
    E[Kode lain] -- emit\ntrigger --> A
// contoh: state management sederhana berbasis EventEmitter
interface AppState {
  user: { id: string; nama: string } | null;
  tema: "light" | "dark";
  bahasa: string;
}

type StateEvents = {
  [K in keyof AppState as `state:${K}:changed`]: [
    newValue: AppState[K],
    oldValue: AppState[K]
  ];
} & {
  "state:changed": [key: keyof AppState, newValue: unknown];
};

class Store extends EventEmitter {
  private state: AppState = {
    user: null,
    tema: "light",
    bahasa: "id",
  };

  getState(): Readonly<AppState> {
    return { ...this.state };
  }

  setState<K extends keyof AppState>(key: K, value: AppState[K]): void {
    const oldValue = this.state[key];

    if (JSON.stringify(oldValue) === JSON.stringify(value)) {
      return; // tidak ada perubahan — tidak perlu emit
    }

    this.state[key] = value;

    // emit event spesifik untuk key yang berubah
    this.emit(`state:${key}:changed` as string, value, oldValue);

    // emit event umum untuk semua perubahan
    this.emit("state:changed", key, value);
  }
}

// penggunaan
const store = new Store();

// observer spesifik untuk perubahan user
store.on("state:user:changed", (newUser, oldUser) => {
  if (newUser) {
    console.log(`Login: ${newUser.nama}`);
  } else {
    console.log(`Logout dari: ${oldUser?.nama}`);
  }
});

// observer untuk semua perubahan state
store.on("state:changed", (key, value) => {
  console.log(`State berubah: ${String(key)} =`, value);
});

store.setState("user", { id: "u1", nama: "Budi" });
// Output:
// Login: Budi
// State berubah: user = { id: 'u1', nama: 'Budi' }

store.setState("tema", "dark");
// Output:
// State berubah: tema = dark

Menghindari Memory Leak #

Memory leak dengan EventEmitter terjadi saat listener ditambahkan tapi tidak pernah dihapus — terutama dalam komponen yang dibuat ulang berkali-kali.

// ANTI-PATTERN: listener tidak dibersihkan saat komponen dihancurkan
class KomponenSalah {
  private emitter: EventEmitter;

  constructor(emitter: EventEmitter) {
    this.emitter = emitter;

    // ✗ listener ini tidak pernah dihapus meski KomponenSalah di-garbage collect
    this.emitter.on("data", this.handleData.bind(this));
  }

  private handleData(data: unknown): void {
    console.log(data);
  }
  // tidak ada cleanup!
}

// BENAR: selalu sediakan metode destroy/cleanup
class Komponen {
  private boundHandler: (data: unknown) => void;

  constructor(private emitter: EventEmitter) {
    // simpan referensi ke bound handler agar bisa dihapus nanti
    this.boundHandler = this.handleData.bind(this);
    this.emitter.on("data", this.boundHandler);
  }

  private handleData(data: unknown): void {
    console.log(data);
  }

  // panggil ini saat komponen tidak lagi dibutuhkan
  destroy(): void {
    this.emitter.off("data", this.boundHandler);
  }
}

// pola dengan AbortController untuk cleanup otomatis
function listenSekali(
  emitter: EventEmitter,
  event: string,
  handler: (...args: unknown[]) => void,
  signal: AbortSignal
): void {
  if (signal.aborted) return;

  emitter.on(event, handler);

  // hapus listener saat signal di-abort
  signal.addEventListener("abort", () => {
    emitter.off(event, handler);
  }, { once: true });
}

// penggunaan dengan AbortController
const controller = new AbortController();
const emitter = new EventEmitter();

listenSekali(emitter, "data", (d) => console.log(d), controller.signal);

emitter.emit("data", "pesan 1"); // diproses
emitter.emit("data", "pesan 2"); // diproses

controller.abort(); // cleanup otomatis

emitter.emit("data", "pesan 3"); // tidak diproses — listener sudah dihapus

EventEmitter vs Pattern Lain #

Gunakan EventEmitter untuk:
  ✓ Komponen yang perlu diobservasi oleh banyak pihak tanpa coupling langsung
  ✓ Domain event — sesuatu yang "terjadi" dalam sistem (pesanan dibuat, user login)
  ✓ Progress tracking — laporkan kemajuan operasi panjang ke observer
  ✓ Plugin system — izinkan kode eksternal bereaksi terhadap event internal
  ✓ Integrasi dengan Node.js stream, HTTP server, dan API berbasis event lainnya

Pertimbangkan alternatif jika:
  ✗ Hanya satu listener — callback atau Promise lebih sederhana
  ✗ Butuh type safety ketat tanpa boilerplate — pertimbangkan library seperti mitt atau eventemitter3
  ✗ State management kompleks — pertimbangkan pattern seperti Redux atau Zustand
  ✗ Komunikasi antar service — gunakan message broker (Redis pub/sub, RabbitMQ, Kafka)

Ringkasan #

  • Selalu daftarkan listener error pada setiap EventEmitter yang bisa memancarkan error — jika tidak ada listener saat emit("error", ...) dipanggil, proses Node.js akan crash.
  • Simpan referensi listener untuk bisa dihapus — arrow function inline tidak bisa di-off() karena setiap kali ditulis menghasilkan fungsi yang berbeda; selalu simpan ke variabel.
  • once() dari modul events memungkinkan await satu event secara langsung — jauh lebih bersih dari membungkus dengan new Promise() manual.
  • Typed EventEmitter dengan interface adalah investasi yang worth it untuk project besar — mencegah typo nama event dan payload yang salah tipe yang baru terdeteksi saat runtime.
  • Pantau jumlah listener dengan listenerCount() dan beri batas wajar dengan setMaxListeners() — warning “possible memory leak” dari Node.js adalah sinyal nyata, bukan sekadar peringatan.
  • Selalu sediakan metode destroy() atau cleanup di komponen yang mendaftarkan listener ke emitter eksternal — listener yang tidak dibersihkan adalah sumber memory leak yang paling umum dengan EventEmitter.
  • prependListener() untuk handler yang harus berjalan sebelum handler lain — berguna untuk middleware atau interceptor yang perlu memproses event sebelum handler utama.
  • Gunakan on() dari modul events untuk mengiterasi event sebagai async iterator — sangat bersih untuk memproses stream event dengan for await...of.

← Sebelumnya: Buffer   Berikutnya: Timers →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact