Skip to content

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...of instead of callbacks
  • Processing Node.js EventEmitter events as a stream
  • Bridging any event source to async iteration for composition with debounce, chunksAsync, or Repeater.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:

  1. Auto-detect API — checks for on/off (Node.js EventEmitter) or addEventListener/removeEventListener (DOM EventTarget).
  2. Register listener — attaches the listener immediately.
  3. Push events — each event emission calls push() to enqueue the value for the consumer.
  4. Multi-arg handling — if the event emits multiple arguments, they are wrapped in an array. Single arguments are pushed directly.
  5. Cleanup on stop — when the consumer breaks out of the for await loop or calls iterator.return(), stop resolves and the listener is removed.

Trade-offs

ApproachProsCons
fromEvent + RepeaterAuto cleanup, composableNeeds Repeater
Node.js on(emitter, event)Built-inNode.js only
Manual listener + queueNo dependencyVerbose, leak-prone
RxJS fromEventRich operatorsLarge 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));
}