The Event Bus: Great for Decoupling, Great for Making a Mess

Jul 23, 2026
11 min read
The Event Bus: Great for Decoupling, Great for Making a Mess

The last cluster was about making state inside a feature explicit and well-modeled. This one is about what happens between features — how the parts of a growing app talk to each other. And the very first tool people reach for is also the one most likely to undo all that hard-won explicitness: the global event bus.

The pitch is seductive. Component A, buried deep in one part of the tree, needs to tell component B, buried in a completely different part, that something happened. Lifting state up to their common ancestor means threading it through ten components that don't care. Prop drilling. Context that spans half the app. It all feels heavy. So you drop in an event bus — bus.emit('cartUpdated') here, bus.on('cartUpdated', ...) there — and suddenly A and B are talking without knowing about each other. Decoupled. Clean. Done.

Except you didn't remove the coupling. You hid it.

Decoupling versus hiding

There's a real difference between two components being decoupled and two components being secretly coupled through a channel you can't see. When A calls a function B gave it, the connection is explicit — you can find it, type it, trace it. When A emits 'cartUpdated' into a global bus and B happens to listen, the connection still exists — B breaks if A stops emitting — but nothing in the code shows it. You can't right-click emit('cartUpdated') and find the listeners. You can't tell, six months later, whether anyone still cares about that event or whether you can safely stop firing it.

This is action-at-a-distance, and it's the exact thing the dependencies article warned about: the real architecture is the graph of who depends on whom, and a bus makes edges of that graph invisible. Fire-and-forget quietly becomes fire-and-pray.

An event bus doesn't remove coupling. It converts visible coupling into invisible coupling — and invisible coupling is the kind that rots.

I'm not saying never use one. I'm saying the bus is not a way to avoid designing your data flow. It's a specific tool for a specific shape of problem, and using it for the wrong shape is how apps turn into a séance where components whisper to each other and nobody knows who's in the room.

The shape a bus is actually for

Here's my test. A bus is the right tool when the emitter legitimately should not know who cares — when the coupling, if you made it explicit, would be accidental rather than real. That's a narrow but genuine category:

  • One-to-many, fire-and-forget. "The user logged out." Auth doesn't know — and shouldn't know — that the cart cache, the websocket connection, and the draft autosaver all need to react. Making auth import and call all three would be a false dependency pointing the wrong way.
  • Cross-cutting concerns. Analytics, logging, toasts. A "payment succeeded" event that a toast system and an analytics layer both react to — neither of which the payment code should depend on.
  • Truly ambient signals. "Went offline," "theme changed," "session expiring soon" — things many unrelated parts respond to independently.

Notice the pattern: in every case, wiring it explicitly would force a module to depend on things beneath it or beside it that it has no business knowing. The bus is right precisely because the alternative is a worse dependency.

And here's the flip side — when there's a real, traceable data dependency, use the explicit tool. If B needs a value that A owns, that's not an event, that's state: put it in a store or pass it down. If A needs B to do something and A legitimately owns that relationship, that's a function call or a callback prop. Don't reach for events to dodge the work of designing where data lives. Using a bus to move owned state around is the single most common way this goes wrong.

Untyped buses are where the mess compounds

Even for a legitimate use, the default event bus API is a trap: stringly-typed names and any payloads.

// the mess: no autocomplete, no safety, no discoverability bus.emit('user_loggedOut', { usrId: 42 }); // typo in event name bus.on('user_logged_out', (data) => { // ...silently never fires clearCache(data.userId); // payload shape? who knows });

Two different spellings of the same event, a payload field that might be usrId or userId depending on who wrote which side, and zero help from the compiler. Multiply that across a codebase and the bus becomes a pile of magic strings nobody dares touch. The fix is a typed event map, so the bus knows every event and its payload:

type AppEvents = { 'auth/loggedOut': { userId: number }; 'cart/updated': { itemCount: number }; 'payment/succeeded': { orderId: string; amountCents: number }; }; function createTypedBus<Events extends Record<string, unknown>>() { const listeners = new Map<keyof Events, Set<(payload: never) => void>>(); return { on<K extends keyof Events>(type: K, fn: (payload: Events[K]) => void) { const set = listeners.get(type) ?? new Set(); set.add(fn as (payload: never) => void); listeners.set(type, set); return () => set.delete(fn as (payload: never) => void); // unsubscribe }, emit<K extends keyof Events>(type: K, payload: Events[K]) { listeners.get(type)?.forEach((fn) => (fn as (p: Events[K]) => void)(payload)); }, }; } export const bus = createTypedBus<AppEvents>();

Now bus.emit('auth/loggedOut', { userId: 42 }) autocompletes, the payload is checked, and a typo in the event name is a compile error. Just as importantly, AppEvents is a catalog — one place that lists every event the app can raise. The bus stops being a fog of strings and becomes a documented contract. (You don't need to hand-roll this; mitt and similar libraries take a typed event map. But seeing the shape is the point.)

Keeping the bus from becoming a god object

The last discipline: scope it. One global bus that every feature emits into and listens to is just global mutable state wearing a bow tie — the god object the shared-code article is allergic to. Prefer buses scoped to a boundary: a feature-level bus for events within a feature, and a small, deliberate set of truly app-wide events at the top. Treat the app-wide event catalog like a public API — small, stable, reviewed when it changes. If your AppEvents type has forty entries, that's not decoupling, that's a distributed spaghetti dinner.

Because here's the thing that took me a while to accept: an event bus doesn't make coupling go away, it makes coupling a design decision you have to be deliberate about. Used for the narrow set of genuinely ambient, one-to-many, cross-cutting signals, it's the right tool and nothing else comes close. Used as a general-purpose "let me talk to that far-away component without wiring anything," it reintroduces every bit of implicit, untraceable magic the last five articles worked to remove — just with a nicer-sounding name.

That framing — a bus is fine, an undisciplined bus is the enemy — sets up the next one perfectly, because there's a specific kind of switch that teams love to sprinkle everywhere with exactly the same "it's just a quick toggle" energy, and it multiplies your code paths behind your back: the feature flag. That's next.

If your app has an event bus, do one experiment: try to list every event it carries and who listens to each. If you can't do it from memory or from one file, the bus has already started hiding your architecture from you — tell me how long the list turned out to be.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.