--- title: "The Event Bus: Great for Decoupling, Great for Making a Mess" author: "Maksym Kuzmitskyi (MaximusFT)" published: "2026-07-31T23:04:07.812Z" updated: "2026-08-22T03:05:00.163Z" url: "https://zyvop.com/the-event-bus-great-for-decoupling-great-for-making-a-mess-nkrmh" canonical: "https://ma-x.im/blog/react-playbook-event-bus" tags: ["react","React Playbook","Architecture"] --- # The Event Bus: Great for Decoupling, Great for Making a Mess ![Cover image]() ![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. ```ts // 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: ```ts type AppEvents = { 'auth/loggedOut': { userId: number }; 'cart/updated': { itemCount: number }; 'payment/succeeded': { orderId: string; amountCents: number }; }; function createTypedBus>() { const listeners = new Map void>>(); return { on(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(type: K, payload: Events[K]) { listeners.get(type)?.forEach((fn) => (fn as (p: Events[K]) => void)(payload)); }, }; } export const bus = createTypedBus(); ``` 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.