EventBus — Typed Event Sequences & State Reduction
1.17 kB minified + gzipped
Coordinating multi-step workflows like order checkouts, multi-factor authentication, or data import pipelines across decoupled modules with raw event emitters quickly leads to fragile code:
This pattern introduces four failure modes:
- Zero payload type safety: Event names are untyped strings (
"shippingAddressEntered"). Misspelled keys fail silently at runtime, and payloads collapse toany. - Fragile manual sequence state: Every multi-step sequence requires independent mutable flags
(
hasAddress,hasPayment) and manual cleanup. If a step is cancelled or times out, flags remain stuck in partial states. - Re-entrant call stack corruption: When a listener reacts to an incoming event by immediately emitting a new event, standard event emitters execute listeners recursively on the call stack. This scrambles chronological dispatch order and causes stack overflow crashes during event cascades.
- Memory leaks: Listeners must be manually tracked and detached on component unmount; missing a
.off()call leaks closures and retains large object graphs.
EventBus models events as a typed schema map. It couples strongly typed payloads with native
sequence matching, pure state reduction, and breadth-first trampoline dispatching.
flowchart TB
Emit["EventBus.emit(bus, message)"] --> Queue["Breadth-First Trampoline Queue (FIFO)"]
Queue --> Dispatch["Dispatch to Active Subscribers"]
subgraph Subscribers ["Subscribers by Capability"]
Dispatch --> Tap["Stateless Side-Effect (.tap)"]
Dispatch --> Reduce["State Accumulator (.reduce)"]
Dispatch --> Collect["Sequence Matcher (.collect)"]
Dispatch --> Forward["Cross-Bus Forwarding (EventBus.forward)"]
end
Type Structure
Section titled “Type Structure”An EventBus<S> represents a message pipeline constrained to a schema map S. Each schema map
defines message kind string keys paired with payload types:
Every message emitted through an event bus is structured as a plain discriminated union containing
kind (the message identifier) and value (the payload).
Creating Event Buses
Section titled “Creating Event Buses”Event buses are constructed using EventBus.make. An optional configuration object allows naming
the bus for debugging or attaching an error boundary callback to isolate subscriber exceptions from
halting sibling listeners:
The id labels the bus instance in logs and DevTools ([object EventBus(checkout-pipeline)]). The
onError callback receives both the uncaught exception and contextual execution metadata (busId
and the active message).
Emitting Messages
Section titled “Emitting Messages”Messages are published using EventBus.emit, which accepts the target event bus (or an array of
buses for broadcasting) and the message object. The compiler enforces that value matches the
payload type declared for that kind:
Subscribing to Events
Section titled “Subscribing to Events”EventBus.subscribe registers subscribers on an event bus. Omitting the event filter subscribes to
all events defined on the bus schema, while providing a key or array of keys targets specific events
or sequences.
Each call to .reduce(), .tap(), or .collect() registers an independent subscription with its
own sequence tracking state and disposal function.
Schema-Wide Subscriptions
Section titled “Schema-Wide Subscriptions”When building telemetry collectors, audit loggers, or global metrics sinks across an entire bus,
call EventBus.subscribe(bus) without specifying event keys. The callback receives a discriminated
union of all messages defined on the bus schema:
Targeted Subscriptions
Section titled “Targeted Subscriptions”When targeting specific event keys, EventBus.subscribe narrows the message payload directly in the
handler:
Handling Events
Section titled “Handling Events”EventBus.subscribe provides three consumption methods depending on whether you need stateless
side-effects, accumulated state, or aggregated sequence payloads.
Stateless Side-Effects (.tap)
Section titled “Stateless Side-Effects (.tap)”.tap(effect) executes a side-effect callback when a matching event arrives. It returns a cleanup
function to remove the subscriber:
Stateful Accumulation (.reduce)
Section titled “Stateful Accumulation (.reduce)”.reduce(reducer, initialState) accumulates derived state over incoming events using a pure reducer
function. It returns a Subscription<State> handle with .getState() and .unsubscribe():
Sequence Payload Aggregation (.collect)
Section titled “Sequence Payload Aggregation (.collect)”.collect(callback) registers a terminal subscriber that aggregates payloads from multiple steps
into a single keyed record ({ [Event]: Payload }). The callback executes once all required events
have arrived, recurring on every completion unless { once: true } is configured:
Sequence Matching Options
Section titled “Sequence Matching Options”When subscribing to multiple event keys, pass a SequenceOptions record to configure order,
strictness, and reset conditions:
Matching behavior follows these rules:
ordered: true: Events must arrive in the exact order specified in the array. In relaxed mode (strict: false), intervening irrelevant events are ignored.strict: true: In ordered sequences, events must arrive consecutively without any intervening events from the schema. An unexpected event immediately resets sequence tracking to index 0.optional: [...]: Configures events that may be skipped without breaking sequence progress. In.collect(), skipped optional steps evaluate toundefined, and TypeScript types them asT | undefinedto enforce safe narrowing.reset: [...]: Specifies one or more event kinds that immediately reset sequence progress and clear partially aggregated payloads when emitted.once: true: Automatically detaches the subscriber before executing the callback upon the first match, preventing re-entrant cycles and duplicate firings.
Structural Event Bus Forwarding
Section titled “Structural Event Bus Forwarding”EventBus.forward routes messages from a source event bus to one or more target buses. Using the
only filter permits forwarding shared event keys between buses with distinct domain schemas:
When only is omitted, the target bus schema must accept every event defined on the source bus.
Synchronous Breadth-First Dispatch
Section titled “Synchronous Breadth-First Dispatch”When a subscriber reacts to an incoming event by immediately emitting a new event (EventBus.emit
called inside a subscriber callback), EventBus dispatches messages through an internal synchronous
trampoline queue.
Rather than invoking re-entrant emissions recursively on the call stack, nested messages are appended to the bus queue. The active dispatch loop delivers the current message to all registered subscribers completely before processing subsequent queued messages:
sequenceDiagram
participant Caller
participant EventBus as EventBus (Trampoline Queue)
participant Sub1 as Subscriber 1
participant Sub2 as Subscriber 2
Caller->>EventBus: emit("EVENT_A")
EventBus->>Sub1: deliver("EVENT_A")
Note over Sub1: Sub1 calls emit("EVENT_B")
Sub1->>EventBus: enqueue("EVENT_B") [Queue: EVENT_B]
EventBus->>Sub2: deliver("EVENT_A")
Note over EventBus: EVENT_A finished for all subscribers
EventBus->>Sub1: deliver("EVENT_B")
EventBus->>Sub2: deliver("EVENT_B")
This dispatch architecture enforces two invariants:
- Strict Causal Ordering: Parallel subscribers always observe messages in chronological FIFO order. No subscriber will observe a re-entrantly triggered event before finishing the event that caused it.
- Call Stack Protection: Deeply cascading event chains execute sequentially inside a flat loop in O(1) stack space, preventing call stack exhaustion without introducing asynchronous microtask delays.
Driving State Machines with Machine.interpret
Section titled “Driving State Machines with Machine.interpret”While EventBus broadcasts messages across decoupled modules, an event bus does not track workflow
state. Passing an EventBus to Machine.interpret connects the event bus to a
stateful gatekeeper:
Bridging an event bus with a state machine prevents three failure modes:
- Out-of-order action corruption: Messages arriving when the machine is in an incompatible state are dropped safely as idempotent no-ops.
- Dangling subscriber leaks: Calling
actor.abort()unbinds the actor from the bus automatically when UI views unmount. - Cascading exception containment: Subscriber errors inside machine entry hooks or transition handlers are isolated by the bus’s error boundary without crashing sibling subscribers.
Pipeline Composition
Section titled “Pipeline Composition”EventBus operations integrate cleanly with pipe for batch processing, telemetry streaming, and
multi-stage event transformations:
Problems it solves
Section titled “Problems it solves”- Decoupling event producers from subscribers: Modules broadcast lifecycle events through a strongly typed schema map without retaining hardcoded references to UI views, logging sinks, or analytics trackers.
- Pattern-matching multi-step event sequences: Tracks multi-step flows (such as registration → verification → onboarding completion) natively with configurable ordering, strict consecutive matching, optional steps, and reset events.
- Preventing re-entrant message reordering: Trampoline FIFO queue dispatching guarantees that nested emissions triggered by subscriber callbacks never jump ahead of primary events.
- Eliminating call stack overflow crashes: Cascading domain events execute sequentially inside an iterative dispatch loop using O(1) stack overhead, preventing recursion crashes.
- State reduction over matched event streams: Calling
.reduce()accumulates live derived state directly from event streams synchronously without external mutable stores. - Structural event bus forwarding:
EventBus.forwardbridges filtered subsets of events between feature modules and root application buses with compile-time schema compatibility verification. - Memory leak prevention: Subscriptions return explicit disposal functions that immediately clean up subscriber sets and prevent dangling closures on component unmount.