When two parts of a page need to talk, the usual move is to add an event bus: a small class or a package with emit, on and off.
eventBus.emit('user:login', user)The browser already ships that. This is the same call with native custom events:
window.dispatchEvent(
new CustomEvent('user:login', {
detail: user,
}),
)It is longer, but it does the same job. Subscribing and unsubscribing use the DOM methods you already know:
const onLogin = (event) => {
console.log(event.detail)
}
window.addEventListener('user:login', onLogin)
window.removeEventListener('user:login', onLogin)What you get without a library
- No class to write, import or keep in sync across bundles. Two separate scripts share the bus because they share
window. - Listeners are visible in browser DevTools.
- Code outside your app can join in. A third-party script can dispatch
user:loginor listen for it. - You can cancel a listener with an
AbortController: pass{ signal }toaddEventListenerand callabort()to remove it along with others that share the signal.
const controller = new AbortController()
window.addEventListener('user:login', onLogin, { signal: controller.signal })
window.addEventListener('user:logout', onLogout, { signal: controller.signal })
// Later: removes both listeners
controller.abort()The part people skip: types
Raw CustomEvent has one real weakness in TypeScript. The event name is a free string and detail is any, so a typo in either place compiles. The fix is to tell TypeScript which events exist by extending WindowEventMap:
declare global {
interface WindowEventMap {
'user:login': CustomEvent<User>
}
}Use declare global when the file is a module, meaning it has an import or export. In an ambient file such as env.d.ts, write a plain declare interface WindowEventMap { ... }. With this in place, window.addEventListener('user:login', (event) => ...) knows event.detail is a User, and an unknown event name falls back to the generic Event type.
Dispatching is still loose. new CustomEvent('user:login', { detail }) does not check that detail matches the map, so nothing stops you from passing the wrong shape.
Typed on both sides
@zoxon/eventor closes that gap. It is a thin wrapper, with no bus inside: it calls window.addEventListener, window.removeEventListener and window.dispatchEvent for you, and uses your WindowEventMap to type the calls.
import { dispatchCustomEvent, listenEvent } from '@zoxon/eventor'
const unlisten = listenEvent('user:login', ({ detail }) => {
console.log(detail.name) // detail is User
})
dispatchCustomEvent('user:login', user) // checked against CustomEvent<User>
dispatchCustomEvent('user:login', 'oops') // type error
unlisten()listenEvent returns its own cleanup function, so you do not need to keep the handler reference around for removeEventListener. That is the only thing it adds. If you delete the library tomorrow, the events keep working and you replace three function calls with the DOM methods above. That is why I use it: there is no abstraction to migrate away from.
This is also how Nux components talk to each other.
When you still want a bus
Native events are global and synchronous, and they do not replay. I would reach for something else when:
- You need scoping.
windowis one namespace, so two features can collide on an event name. A prefix likemodal:showhelps, andnew EventTarget()gives you a private bus with the same API when it is not enough. - A late subscriber must receive events that fired earlier. Custom events do not buffer.
- Events cross tabs or windows.
BroadcastChannelfits that case. - You need wildcards, middleware or async ordering guarantees.
For a handful of UI events on one page, a bus is extra code for something the platform already does.