You need a framework-free way for a plain JavaScript service object — a data store, not a DOM node — to notify subscribers. What does `class Store extends EventTarget` give you, and what does it not?
answer
- the platform already ships one
- subclass it, no library needed
- nothing above it in any tree
- you cannot ask who is listening
- teardown rides on the same signal
basics
~20 sExtending EventTarget gives any object the standard addEventListener, removeEventListener and dispatchEvent methods, including listener options such as once and signal. It gives no propagation, no listener introspection, and no way to know whether anyone subscribed.
solid answer
~50 s`EventTarget` has been constructible and subclassable for years, so `class Store extends EventTarget {}` turns a plain object into a real event emitter with zero dependencies. Subscribers use the exact same API they already use for DOM nodes — `store.addEventListener('changed', fn)`, plus the standard listener options — and you emit with `this.dispatchEvent(new CustomEvent('changed', { detail }))`. What you do not get is anything tree-shaped: there is no bubbling, capture phase or `composed` behaviour, because a bare EventTarget has no parent, so those init flags are inert. You also cannot enumerate listeners or ask whether anyone is subscribed, listener exceptions are reported globally rather than thrown back at you, and every payload has to be wrapped in an Event object rather than passed as loose arguments. For most application code that trade is worth it: one fewer library, an API every developer already knows, and free integration with AbortController for teardown.
code
javascript · 20 linesclass Store extends EventTarget {
#state = { count: 0 };
get state() { return this.#state; }
increment() {
this.#state = { count: this.#state.count + 1 };
this.dispatchEvent(new CustomEvent('changed', { detail: this.#state }));
}
}
const store = new Store();
const controller = new AbortController();
store.addEventListener('changed', (e) => console.log(e.detail.count), {
signal: controller.signal
});
store.increment(); // 1
store.increment(); // 2
controller.abort(); // every subscription tied to the signal is gone
store.increment(); // nothing loggedgo deeper
Know that EventTarget can be constructed and extended, so a plain class can gain addEventListener and dispatchEvent without any library.
Explain what transfers and what does not: listener options and synchronous delivery come along, while bubbling, capture and composed are meaningless for an object outside any tree.
Weigh it against an emitter library in production terms — no dependency and familiar semantics, against no introspection, no error channel back to the emitter, and subscriptions that retain closures until removed.
Set the house pattern: subclass or compose, namespaced event names with documented detail shapes, and a teardown discipline such as one AbortController per subscriber so a long-lived bus never becomes a leak.
## The one-line emitter Before `EventTarget` became constructible, giving a non-DOM object events meant pulling in an emitter library or writing a `Map` of callback arrays by hand. Now the platform supplies it: ```js class Store extends EventTarget { #state = { count: 0 }; get state() { return this.#state; } increment() { this.#state = { count: this.#state.count + 1 }; this.dispatchEvent(new CustomEvent('changed', { detail: this.#state })); } } const store = new Store(); store.addEventListener('changed', (e) => render(e.detail.count)); store.increment(); ``` The subclass inherits `addEventListener`, `removeEventListener` and `dispatchEvent`. You can also use `new EventTarget()` directly as a standalone bus if you do not want a class. ## What you genuinely get **A familiar API.** Anyone who has used the DOM already knows how to subscribe. There is no `.on`/`.off`/`.emit` dialect to learn and no divergence between how you listen to a button and how you listen to your store. **The standard listener options.** Because subscription goes through the real `addEventListener`, the options come along: `{ once: true }` for one-shot subscriptions, and `{ signal }` so an `AbortController` can drop many subscriptions at once. A component that already aborts its DOM listeners on teardown can pass the same signal to the store and unsubscribe everything in one call. **Synchronous, isolated delivery.** Listeners run inline during `dispatchEvent`, and one throwing listener does not break the others or the emitter. **Structured payloads.** `CustomEvent`'s `detail` is a documented slot, so subscribers always know where the data lives. ## What you do not get **Anything tree-shaped.** A standalone EventTarget has no parent, so there is nothing for an event to bubble to. Setting `bubbles: true` or `composed: true` on an event dispatched at a bare EventTarget is harmless but meaningless — the dispatch path is just the target itself. If you want events from a child store to reach a parent store, you must forward them explicitly. **Introspection.** There is no API to list listeners, count them, or ask "is anyone subscribed to `changed`?". `dispatchEvent` returns `true` whether ten listeners ran or none did. If you need lazy work only when someone is listening, track subscription yourself by wrapping `addEventListener` — you cannot query it after the fact. **Errors flowing back.** A listener that throws produces a reported uncaught error, not an exception at your `dispatchEvent` call site. The emitter cannot detect subscriber failure; a subscriber that must report failure has to do it through the payload. **Loose argument passing.** Emitter libraries typically let you write `emit('changed', a, b, c)`. Here every emission allocates an Event object and everything must be packed into `detail`. That is slightly more ceremony and a small allocation per emit — irrelevant at UI frequencies, worth measuring in a very hot loop. **Any wildcard or namespacing.** There is no `on('*')`, and matching is exact string equality on the type. ## Design notes for real use Subclassing versus composing is a real choice. `extends EventTarget` is the least code, but it puts `addEventListener` and `dispatchEvent` on your public surface — meaning any caller can dispatch a fake `changed` event on your store. If that matters, hold a private EventTarget instead and expose narrow methods: ```js class Store { #bus = new EventTarget(); subscribe(fn, options) { this.#bus.addEventListener('changed', fn, options); } #emit(detail) { this.#bus.dispatchEvent(new CustomEvent('changed', { detail })); } } ``` This keeps emission private while still reusing the platform's listener bookkeeping and option handling. Second note: subscription is a retention edge. A listener registered on a long-lived store keeps its closure, and whatever the closure captures, alive until it is removed. The `signal` option is the cleanest discipline — tie every subscription to the subscriber's lifetime and abort once. Finally, keep event names namespaced and their `detail` shapes documented. Once several modules subscribe to the same store, those names are an API in every sense, and the fact that the platform will happily accept any string makes it your job, not the platform's, to keep them coherent.
- Do bubbles and composed do anything on an event dispatched at a bare EventTarget?No. Those flags describe travel through a node tree, and a standalone EventTarget has no parent — the dispatch path is just the target. Setting them is harmless but inert. If a child store's events should reach a parent, you have to subscribe and re-dispatch explicitly; there is no automatic propagation.
- How do you unsubscribe a subscriber that registered many listeners on such a store?Pass the same `AbortController` signal in the options of every `addEventListener` call, then call `controller.abort()` once at teardown. Because subscription goes through the real addEventListener, that platform option works identically on a custom EventTarget, and it removes the need to keep function references around for removeEventListener.
- Can the store find out whether anyone is subscribed before doing expensive work?Not through the platform — there is no listener-introspection API, and dispatchEvent returns true whether ten listeners ran or none. If you need that, wrap subscription yourself: expose a subscribe() method that increments a counter or tracks types, and check your own bookkeeping before computing the payload.
- When would you compose an EventTarget privately instead of subclassing it?When you do not want dispatchEvent on your public surface. Subclassing lets any caller fire a fake event on your object and lets them listen for internal types you never meant to publish. Holding a private EventTarget and exposing a narrow subscribe() keeps emission an internal privilege while still reusing the platform's listener bookkeeping.
saying these in an interview costs you the question
- Expects events to bubble from a child object to a parent object
- Believes EventTarget cannot be constructed or subclassed
- Assumes a way exists to list or count current listeners
- Reads dispatchEvent's true return as proof someone was listening
- Never removes listeners from a long-lived store and calls it stateless