Event Emission is a high-performance, type-safe event primitive designed to bridge the gap between three worlds: DOM EventTarget, TC39 Observable, and AsyncIterator. While standard event emitters often force you into a single consumption pattern, Event Emission gives you the freedom to dispatch once and consume however your logic demands—whether that's standard callbacks, reactive pipelines via RxJS, or clean for await...of loops. By treating events as a first-class, composable primitive rather than just a side-effect, it eliminates race conditions and shared mutable state, providing a unified, zero-dependency foundation for building resilient, concurrent applications in modern JavaScript and TypeScript environments.
A lightweight, zero-dependency, type-safe event system with DOM EventTarget ergonomics and TC39 Observable interoperability. Use one event source with callbacks, async iterators, and RxJS without losing TypeScript safety.
- Typed events - Event maps keep payloads and event names in sync
- DOM compatible -
EmissionEventis a superset of the built-inEvent - Familiar API -
addEventListener,removeEventListener,dispatchEvent - TC39 Observable - Fully compliant
Observableimplementation (passes alles-observable-tests) - Async iteration -
for await...ofover events with backpressure options - Wildcard listeners - Listen to
*or namespaceduser:*patterns - Observable state - Proxy any object and emit change events automatically
- AbortSignal support - Cleanup with AbortController
- No dependencies - Framework-agnostic, works in Node, Bun, and browsers
- Typed app-level event buses
- Bridging DOM events to RxJS pipelines
- State objects that emit change events for UI updates
- Component/service emitters without stringly-typed payloads
npm install event-emission
# or
bun add event-emission
# or
pnpm add event-emissionimport { createEventTarget } from 'event-emission';
type UserEvents = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
error: Error;
};
const events = createEventTarget<UserEvents>();
events.addEventListener('user:login', (event) => {
console.log(`User ${event.detail.userId} logged in at ${event.detail.timestamp}`);
});
events.dispatchEvent({
type: 'user:login',
detail: { userId: '123', timestamp: new Date() },
});- Event map: a TypeScript type that maps event names to payload types.
- Event shape:
{ type: string; detail: Payload; bubbles?: boolean; cancelable?: boolean; composed?: boolean }for dispatch. - Unsubscribe:
addEventListenerreturns a function to remove the listener.
Core (event-emission):
createEventTarget<E>(options?)createEventTarget(target, { observe: true, ... })EventEmission<E>base class
Optional subpaths:
Observable<T>compliant implementation (event-emission/observable)- Interoperability:
fromEventTarget,forwardToEventTarget,pipe(event-emission/interoperability) - Observe utilities:
isObserved,getOriginal,setupEventForwarding(event-emission/observe) - Types-only exports (
event-emission/types)
To keep the core entrypoint lean, optional features are exposed via subpath exports:
import { Observable } from 'event-emission/observable';
import { getOriginal, isObserved, setupEventForwarding } from 'event-emission/observe';
import {
forwardToEventTarget,
fromEventTarget,
pipe,
} from 'event-emission/interoperability';
import type { EventTargetLike } from 'event-emission/types';Creates a typed event target.
type Events = {
message: { text: string };
error: Error;
};
const target = createEventTarget<Events>();| Option | Type | Description |
|---|---|---|
onListenerError |
(type: string, error: unknown) => void |
Custom error handler for listener exceptions |
If a listener throws and no onListenerError is provided, an error event is emitted. If there are no error listeners, the error is re-thrown.
Create state objects that emit change events:
const state = createEventTarget({ count: 0, user: { name: 'Ada' } }, { observe: true });
state.addEventListener('update', (event) => {
console.log('State changed:', event.detail.current);
});
state.addEventListener('update:count', (event) => {
console.log(
`Count changed from ${event.detail.previous.count} to ${event.detail.value}`,
);
});
state.count = 1; // Triggers 'update' and 'update:count'
state.user.name = 'Grace'; // Triggers 'update' and 'update:user.name'Observe options:
Deep observation is enabled by default.
| Option | Type | Default | Description |
|---|---|---|---|
observe |
boolean |
false |
Enable property change observation |
deep |
boolean |
true |
Observe nested objects |
cloneStrategy |
'shallow' | 'deep' | 'path' |
'path' |
How to clone previous state |
deepClone |
<T>(value: T) => T |
- | Optional deep clone fallback when structuredClone is unavailable |
Note: cloneStrategy: 'deep' uses structuredClone by default, or deepClone if provided.
Example fallback:
const state = createEventTarget(
{ count: 0, user: { name: 'Ada' } },
{
observe: true,
cloneStrategy: 'deep',
deepClone: (value) => JSON.parse(JSON.stringify(value)),
},
);Update event details:
updateandupdate:pathevents include{ value, current, previous }.- Array mutators emit method events like
update:items.pushwith{ method, args, added, removed, current, previous }.
Creates an Observable for a specific event type. This follows the ObservableEventTarget proposal, allowing for powerful composition.
const clicks = button.on('click', { passive: true });
clicks.subscribe((event) => {
console.log('Clicked!', event.detail);
});Options:
| Option | Type | Default | Description |
|---|---|---|---|
capture |
boolean |
false |
If true, listen during the capture phase |
receiveError |
boolean |
false |
If true, listen for "error" events and forward them to the observer's error method |
handler |
Function |
null |
Optional function to run stateful actions (like preventDefault()) before dispatching |
once |
boolean |
false |
If true, the observable completes after the first event is dispatched |
passive |
boolean |
false |
Indicates that the callback will not cancel the event |
signal |
AbortSignal |
- | Abort signal to remove the listener when aborted |
Adds a listener and returns an unsubscribe function.
const unsubscribe = events.addEventListener('message', (event) => {
console.log(event.detail.text);
});
unsubscribe();Options: (or pass true/false for capture)
| Option | Type | Description |
|---|---|---|
capture |
boolean |
Listen during the capture phase |
once |
boolean |
Remove listener after first invocation |
passive |
boolean |
Listener will not call preventDefault() |
signal |
AbortSignal |
Abort signal to remove listener when aborted |
Note: preventDefault() only affects events dispatched with cancelable: true. dispatchEvent returns false when a cancelable event is prevented.
Adds a one-time listener.
Removes a specific listener.
Removes all listeners, or all listeners for a type.
events.addWildcardListener('*', (event) => {
console.log(`Got ${event.originalType}:`, event.detail);
});
events.addWildcardListener('user:*', (event) => {
console.log(`User event: ${event.originalType}`);
});Wildcard events include { type: pattern, originalType, detail }.
You can use for await...of to consume events. This is great for stream-processing events with backpressure.
// Simple iteration
for await (const event of events.events('message')) {
console.log('Received:', event.detail.text);
}
// With options for backpressure and cleanup
const iterator = events.events('message', {
bufferSize: 16,
overflowStrategy: 'drop-oldest',
signal: abortController.signal,
});
for await (const event of iterator) {
// ...
}Iterator options:
| Option | Type | Default | Description |
|---|---|---|---|
signal |
AbortSignal |
- | Abort signal to stop iteration |
bufferSize |
number |
Infinity |
Maximum buffered events |
overflowStrategy |
'drop-oldest' | 'drop-latest' | 'throw' |
'drop-oldest' |
Behavior when buffer is full |
When overflowStrategy is throw, the iterator throws BufferOverflowError.
const subscription = events.subscribe('message', {
next: (event) => console.log(event.detail),
error: (err) => console.error(err),
complete: () => console.log('Done'),
});
subscription.unsubscribe();Returns an Observable that emits all events.
import { from } from 'rxjs';
import { filter, map } from 'rxjs/operators';
const observable = from(events);
observable
.pipe(
filter((event) => event.type === 'message'),
map((event) => event.detail.text),
)
.subscribe(console.log);A fully compliant implementation of the TC39 Observable proposal.
import { Observable } from 'event-emission/observable';
// Create from items
const numbers = Observable.of(1, 2, 3);
// Create from any iterable or observable-like
const fromArray = Observable.from([10, 20, 30]);
// Manual creation
const custom = new Observable((observer) => {
observer.next('Hello');
observer.complete();
});Marks the event target as complete, clears listeners, and ends iterators.
Removes all listeners without marking as complete.
Extend EventEmission to build typed emitters:
import { EventEmission } from 'event-emission';
class UserService extends EventEmission<{
'user:created': { id: string; name: string };
'user:deleted': { id: string };
error: Error;
}> {
createUser(name: string) {
const id = crypto.randomUUID();
this.dispatchEvent({ type: 'user:created', detail: { id, name } });
return id;
}
}import { fromEventTarget } from 'event-emission/interoperability';
type ButtonEvents = {
click: MouseEvent;
focus: FocusEvent;
};
const button = document.getElementById('my-button');
const events = fromEventTarget<ButtonEvents>(button, ['click', 'focus']);
events.addEventListener('click', (event) => {
console.log('Button clicked!', event.detail);
});
events.destroy();import { createEventTarget } from 'event-emission';
import { forwardToEventTarget } from 'event-emission/interoperability';
const events = createEventTarget<{ custom: { value: number } }>();
const element = document.getElementById('target');
const unsubscribe = forwardToEventTarget(events, element);
events.dispatchEvent({ type: 'custom', detail: { value: 42 } });
unsubscribe();import { createEventTarget } from 'event-emission';
import { pipe } from 'event-emission/interoperability';
const componentEvents = createEventTarget<{ ready: void }>();
const appBus = createEventTarget<{ ready: void }>();
const unsubscribe = pipe(componentEvents, appBus);
unsubscribe();Note: Both pipe(source, target) and events.pipe(target) forward all events via a wildcard listener. Use a map function to transform events or return null to filter.
Event Emission works beautifully with React's useSyncExternalStore for predictable, race-condition-free state synchronization.
import { useSyncExternalStore } from 'react';
import { createEventTarget } from 'event-emission';
const bus = createEventTarget<{ log: string }>();
function useBusEvent() {
return useSyncExternalStore(
(callback) => bus.addEventListener('log', callback),
() => getLatestLogValue(), // implementation depends on your needs
);
}The observe feature is perfect for building high-performance global stores or local controllers that live outside the React render cycle.
import { useSyncExternalStore } from 'react';
import { createEventTarget } from 'event-emission';
// 1. Create your store outside of React
const store = createEventTarget(
{ count: 0, lastUpdated: new Date() },
{ observe: true }
);
// 2. Create a generic hook to sync with any observed target
export function useObservable<T extends object>(target: T) {
return useSyncExternalStore(
(onStoreChange) => (target as any).addEventListener('update', onStoreChange),
() => target
);
}
// 3. Components re-render only when the store actually mutates
function Counter() {
const state = useObservable(store);
return (
<button onClick={() => state.count++}>
Count is {state.count}
</button>
);
}Event Emission works naturally with Svelte 5 Runes to create reactive stores that live outside your component tree.
import { createEventTarget } from 'event-emission';
// 1. Define your external state
const store = createEventTarget({ count: 0 }, { observe: true });
// 2. Create a generic Rune to sync with any observed target
export function useObservable<T extends object>(target: T) {
let state = $state(target);
$effect(() => {
return (target as any).addEventListener('update', () => {
state = target; // Trigger Svelte reactivity
});
});
return state;
}
// 3. Use it in your components
const state = useObservable(store);Checks if an object is an observed proxy.
import { isObserved } from 'event-emission/observe';Returns the original unproxied object.
import { getOriginal } from 'event-emission/observe';import type {
EmissionEvent,
EventTargetLike,
ObservableLike,
Observer,
Subscription,
WildcardEvent,
AddEventListenerOptionsLike,
} from 'event-emission/types';
import type {
ObservableEventMap,
PropertyChangeDetail,
ArrayMutationDetail,
} from 'event-emission/observe';
import type { Subscriber, SubscriptionObserver } from 'event-emission/observable';