Appearance
Event to Async Iterator
Convert any EventEmitter (Node.js) or EventTarget (DOM) into an async iterable using Repeater. Listeners are automatically cleaned up when iteration stops.
When to Use
- Consuming DOM events with
for await...ofinstead of callbacks - Processing Node.js
EventEmitterevents as a stream - Bridging any event source to async iteration for composition with
debounce,chunksAsync, orRepeater.merge
The Pattern
Watch DOM events
javascript
const clicks = fromEvent(button, "click");
for await (const event of clicks) {
handleClick(event);
if (shouldStop) break; // listener is removed
}Stream Node.js events
javascript
const emitter = new EventEmitter();
const values = fromEvent(emitter, "data");
setTimeout(() => {
emitter.emit("data", 1);
emitter.emit("data", 2);
emitter.emit("data", 3);
}, 10);
for await (const value of values) {
console.log(value); // 1, 2, 3
if (value === 3) break;
}Compose with other utilities
javascript
// Debounced resize events
const resizes = fromEvent(window, "resize");
const debounced = debounce(200)(resizes);
for await (const event of debounced) {
recalculateLayout();
}
// Merge multiple event sources
const merged = Repeater.merge([
fromEvent(window, "online"),
fromEvent(window, "offline"),
]);
for await (const event of merged) {
updateConnectionStatus();
}Stop iteration explicitly
javascript
const iterator = fromEvent(emitter, "value");
const collected = [];
for await (const value of iterator) {
collected.push(value);
if (collected.length === 3) {
iterator.return(); // triggers cleanup
}
}Implementing fromEvent()
fromEvent() creates a Repeater that attaches an event listener on construction and removes it when iteration stops. It auto-detects Node.js EventEmitter (.on/.off) or DOM EventTarget (.addEventListener/.removeEventListener):
javascript
import { Repeater } from "@repeaterjs/repeater";
export function fromEvent(emitter, event) {
return new Repeater(async (push, stop) => {
const on =
(emitter.on ?? emitter.addEventListener).bind(emitter);
const off =
(emitter.off ?? emitter.removeEventListener)
.bind(emitter);
on(event, listener);
await stop;
off(event, listener);
function listener() {
const [first, ...rest] = arguments;
if (rest.length > 0) {
push([first, ...rest]);
} else {
push(first);
}
}
});
}How it works:
- Auto-detect API — checks for
on/off(Node.jsEventEmitter) oraddEventListener/removeEventListener(DOMEventTarget). - Register listener — attaches the listener immediately.
- Push events — each event emission calls
push()to enqueue the value for the consumer. - Multi-arg handling — if the event emits multiple arguments, they are wrapped in an array. Single arguments are pushed directly.
- Cleanup on stop — when the consumer breaks out of the
for awaitloop or callsiterator.return(),stopresolves and the listener is removed.
Trade-offs
| Approach | Pros | Cons |
|---|---|---|
fromEvent + Repeater | Auto cleanup, composable | Needs Repeater |
Node.js on(emitter, event) | Built-in | Node.js only |
| Manual listener + queue | No dependency | Verbose, leak-prone |
RxJS fromEvent | Rich operators | Large bundle |
Backpressure consideration: push() without await means events queue unboundedly. For high-frequency events (e.g., mousemove), consider using SlidingBuffer(1) to keep only the latest value:
javascript
import {
Repeater,
SlidingBuffer,
} from "@repeaterjs/repeater";
function fromEventLatest(emitter, event) {
return new Repeater(async (push, stop) => {
const on =
(emitter.on ?? emitter.addEventListener)
.bind(emitter);
const off =
(emitter.off ?? emitter.removeEventListener)
.bind(emitter);
on(event, push);
await stop;
off(event, push);
}, new SlidingBuffer(1));
}