JS::OOP_Mastery
Overview / Session 10
Session 10 of 10  ·  2 hrs + 15 min review  ·  Capstone

Practical Project: Mini Event Emitter

⏱ 2-hour session 📋 15-min review 🟢 Brings together Sessions 01–09 🗂 4 stages

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.

JavaScript — EventEmitter, stage 1
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.

JavaScript — adding off() and once()
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.

JavaScript — static factory + a subclass
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.

JavaScript — the complete Mini Event Emitter
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?

✓ Key concepts checklist

🎉 Back to course overview — you're done!