What We're Building
An EventEmitter — a class-based publish/subscribe (pub/sub) system. Code can on() (subscribe to) named events, and other code can emit() those events with data, triggering every subscriber. This exact pattern is everywhere: Node's built-in EventEmitter, the DOM's addEventListener, most state-management libraries, and any "plugin system" you've used. Building a small one yourself is the fastest way to actually own this pattern instead of just recognizing it.
We'll build it in four stages, each pulling in a specific tool from earlier sessions. By the end you'll have one cohesive, working class — and a clear map of which session taught you each piece.
Stage 1 — Core Structure (Sessions 02, 04)
Start with the shape: a class, a private store for listeners, and the two methods everything else builds on. The listener storage is private — Session 04's lesson — because letting outside code poke directly into the internal listener map is exactly the kind of unguarded state that causes hard-to-trace bugs later.
class EventEmitter { #listeners = {}; // private — Session 04. Shape: { eventName: [fn, fn, ...] } on(eventName, callback) { if (!this.#listeners[eventName]) { this.#listeners[eventName] = []; } this.#listeners[eventName].push(callback); return this; // enables chaining: emitter.on(...).on(...) } emit(eventName, ...args) { const callbacks = this.#listeners[eventName] ?? []; for (const cb of callbacks) { cb(...args); } } } const bus = new EventEmitter(); bus.on('greet', (name) => console.log(`Hello, ${name}!`)); bus.emit('greet', 'Alice'); // "Hello, Alice!"
Notice on() returns this — a small, deliberate choice that lets callers chain emitter.on(...).on(...).on(...), a common convention in real event-driven libraries.
Stage 2 — Unsubscribing & One-Time Listeners
A real emitter needs a way to stop listening — otherwise listeners pile up forever (a memory leak in long-running apps). We'll also add once(), a listener that automatically removes itself after firing a single time.
class EventEmitter { #listeners = {}; on(eventName, callback) { (this.#listeners[eventName] ??= []).push(callback); return this; } off(eventName, callback) { const callbacks = this.#listeners[eventName]; if (!callbacks) return this; this.#listeners[eventName] = callbacks.filter(cb => cb !== callback); return this; } once(eventName, callback) { const wrapper = (...args) => { callback(...args); this.off(eventName, wrapper); // self-removing after first run }; return this.on(eventName, wrapper); } emit(eventName, ...args) { const callbacks = this.#listeners[eventName] ?? []; // .slice() — copy the array before iterating, since once() mutates // the original array WHILE emit() is looping over it for (const cb of callbacks.slice()) { cb(...args); } } }
The once() trick is worth slowing down on: wrapper is the function actually registered with on(); the moment it runs, it removes itself by reference via off() — but it still calls the original callback first. This is a closure (Session 04) capturing both callback and eventName from the surrounding scope.
Stage 3 — A Static Factory & Subclassing It (Sessions 05, 07)
Real applications usually want one shared event bus, plus the option to build specialized emitters on top of the base class. This stage adds a static factory method (Session 07) and demonstrates extending the emitter (Session 05) — the same "is-a" relationship test applies: a Logger that emits log events is an event emitter with extra behaviour layered on, a legitimate inheritance case.
class EventEmitter { // ...on(), off(), once(), emit() from Stages 1–2 ... static #sharedBus; // Session 07 — static state belongs to the class static shared() { if (!EventEmitter.#sharedBus) { EventEmitter.#sharedBus = new EventEmitter(); } return EventEmitter.#sharedBus; } } // A specialized emitter — legitimate "is-a": a Logger IS an EventEmitter // with logging behaviour layered on top (Session 05's is-a/has-a test) class Logger extends EventEmitter { log(message) { const entry = { message, time: new Date().toISOString() }; this.emit('log', entry); // reuses the inherited emit() — Session 05 return entry; } } const logger = new Logger(); logger.on('log', (entry) => console.log(`[${entry.time}] ${entry.message}`)); logger.log('Server started'); // EventEmitter.shared() deliberately avoids a true Singleton (Session 07's // warning) — nothing stops you from also doing `new EventEmitter()` for a // separate, independent bus whenever you actually need one
Notice shared() is named to suggest "here's a convenient default instance," not "this class can only ever have one instance" — a deliberate echo of Session 07's Singleton warning. Calling code can still freely create independent EventEmitter instances whenever it actually wants isolation.
Stage 4 — Putting It All Together
Here's the complete emitter, every stage combined, with the OOP pillars from across the course labeled inline. This is the version worth keeping as a personal reference.
class EventEmitter { #listeners = {}; // Encapsulation — Session 04 static #sharedBus; on(eventName, callback) { (this.#listeners[eventName] ??= []).push(callback); return this; } off(eventName, callback) { const callbacks = this.#listeners[eventName]; if (!callbacks) return this; this.#listeners[eventName] = callbacks.filter(cb => cb !== callback); return this; } once(eventName, callback) { const wrapper = (...args) => { callback(...args); this.off(eventName, wrapper); }; return this.on(eventName, wrapper); } emit(eventName, ...args) { const callbacks = this.#listeners[eventName] ?? []; for (const cb of callbacks.slice()) { cb(...args); } } static shared() { // Static factory — Session 07 if (!EventEmitter.#sharedBus) { EventEmitter.#sharedBus = new EventEmitter(); } return EventEmitter.#sharedBus; } } class Logger extends EventEmitter { // Inheritance — Session 05 log(message) { const entry = { message, time: new Date().toISOString() }; this.emit('log', entry); return entry; } } // Polymorphism in action — Session 06: anything with on()/emit() works here, // whether it's an EventEmitter, a Logger, or a duck-typed object you wrote // yourself that happens to implement the same shape. function wireUpLogging(emitterLike) { emitterLike.on('log', (e) => console.log(`📝 ${e.message}`)); } const logger = new Logger(); wireUpLogging(logger); logger.log('Application started'); // "📝 Application started"
Five sessions' worth of concepts, working together in about 40 lines: a private listener store you can't corrupt from outside (04), instance methods shared cheaply across every instance via the prototype (02–03), a subclass that reuses inherited behaviour through extends (05), a static factory offering a convenient shared instance without forcing a true Singleton (07), and a helper function that works on anything shaped like an emitter, class-based or not (06).
Where to take it next
- Add wildcard listeners — subscribe to '*' to hear every event
- Add error handling so one listener throwing doesn't stop the rest from running
- Build a small state-management store on top of this emitter — emit a 'change' event whenever state updates
- Look at Node's actual EventEmitter source for comparison — you'll recognize almost everything in it now
15-Minute Review — Session 10
Stop the session timer and switch to the review timer in the sidebar. Answer these questions from memory — then check.
Q1 — Why is #listeners declared as a private field instead of a regular public property?
Q2 — How does once() make a listener automatically stop firing after one call?
Q3 — Why does Logger extends EventEmitter represent a legitimate use of inheritance, by the test from Session 05?
Q4 — Why is EventEmitter.shared() a static factory and not a true Singleton, per Session 07's distinction?