Effection Logo

Signals

In the last chapter, we saw that channel.send() is an operation - you need yield* to call it. But what about code that runs outside of generators?

  • DOM event handlers
  • Node.js EventEmitter callbacks
  • setTimeout/setInterval callbacks
  • Promise .then() callbacks
  • Spawned tasks communicating back to their parent

This doesn't work:

// can't use yield* in a callback!
await main(function* () {
  const channel = createChannel<MouseEvent, void>();

  document.addEventListener("click", (event) => {
    yield * channel.send(event); // SyntaxError!
  });
});

You can only use yield* inside a generator function. Callbacks are regular functions.

Signals: Plain Functions That Bridge Worlds

A Signal is like a Channel, but its send() method is a regular function, not an operation:

import type { Signal } from "effection";
import { main, createSignal, each } from "effection";

await main(function* () {
  // Create a signal
  const clicks: Signal<string, void> = createSignal<string, void>();

  // clicks.send is a REGULAR FUNCTION - can be used anywhere!
  setTimeout(() => clicks.send("click 1"), 100);
  setTimeout(() => clicks.send("click 2"), 200);
  setTimeout(() => clicks.send("click 3"), 300);
  setTimeout(() => clicks.close(), 400);

  // Consume as a stream (same as channel)
  for (const click of yield* each(clicks)) {
    console.log("Received:", click);
    yield* each.next();
  }

  console.log("Done");
});

Output:

Received: click 1
Received: click 2
Received: click 3
Done

This is the key insight: signal.send() doesn't need yield*. It's a plain JavaScript function that can be called from anywhere - callbacks, event handlers, spawned tasks, anywhere.

Signal vs Channel

FeatureChannelSignal
send() returnsOperation<void>void
Can call in callbacksNoYes
Use inside operationsyield* channel.send()signal.send()
Streaming consumptionSameSame

Rule of thumb:

  • Both producer and consumer are Effection operations → Channel
  • Producer is a callback or external code → Signal

The Whiteboard vs Inbox Mental Model

Think of the difference this way:

Signal = Whiteboard: Anyone can walk by and write on it (no waiting, no yield). The whiteboard is in a public hallway—callbacks, event handlers, and external code can all scribble on it. Observers check the whiteboard when they're ready.

Channel = Inbox: You need to wait your turn to drop something in the inbox (yield*). The inbox has a proper protocol—both sender and receiver are part of the structured concurrency world, playing by the same rules.

// Whiteboard: External code writes freely
document.addEventListener("click", signal.send); // Just a function call

// Inbox: Operations wait their turn
yield * channel.send(message); // Must yield* to send

The Built-in Event Helpers

Effection provides built-in helpers for working with EventTarget (used by DOM elements and many Node.js APIs).

Single events with once()

The once() operation blocks until an event occurs:

import { main, once } from "effection";

await main(function* () {
  let socket = new WebSocket("ws://localhost:1234");

  yield* once(socket, "open");

  console.log("socket is open!");

  let closeEvent = yield* once(socket, "close");
  console.log("socket closed with code", closeEvent.code);
});

Recurring events with on()

For multiple events, use on() which returns a Stream:

import { main, on, each } from "effection";

await main(function* () {
  let socket = new WebSocket("ws://localhost:1234");

  for (let message of yield* each(on(socket, "message"))) {
    console.log("message:", message.data);
    yield* each.next();
  }
});

Practical Example: DOM Events

In a browser context:

import type { Operation, Signal } from "effection";
import { main, createSignal, each, ensure } from "effection";

function* trackClicks(button: HTMLButtonElement): Operation<void> {
  const clicks: Signal<MouseEvent, void> = createSignal<MouseEvent, void>();

  // Attach the signal's send function directly as the event handler!
  button.addEventListener("click", clicks.send);

  // Clean up when operation ends
  yield* ensure(() => {
    button.removeEventListener("click", clicks.send);
    clicks.close();
  });

  // Process clicks
  for (const event of yield* each(clicks)) {
    console.log("Clicked at:", event.clientX, event.clientY);
    yield* each.next();
  }
}

Practical Example: Node.js EventEmitter

import type { Operation, Signal } from "effection";
import { main, createSignal, spawn, sleep, each, ensure } from "effection";
import { EventEmitter } from "events";

interface DataEvent {
  id: number;
  value: string;
}

function* streamEvents(emitter: EventEmitter): Operation<void> {
  const events: Signal<DataEvent, void> = createSignal<DataEvent, void>();
  const errors: Signal<Error, void> = createSignal<Error, void>();

  // Attach handlers (regular functions)
  const onData = (data: DataEvent) => events.send(data);
  const onError = (err: Error) => errors.send(err);

  emitter.on("data", onData);
  emitter.on("error", onError);

  yield* ensure(() => {
    emitter.off("data", onData);
    emitter.off("error", onError);
    events.close();
    errors.close();
  });

  // Process events
  for (const event of yield* each(events)) {
    console.log("Data event:", event);
    yield* each.next();
  }
}

// Demo
await main(function* () {
  const emitter = new EventEmitter();

  // Start consuming events
  yield* spawn(() => streamEvents(emitter));

  yield* sleep(10);

  // Emit some events
  emitter.emit("data", { id: 1, value: "first" });
  emitter.emit("data", { id: 2, value: "second" });
  emitter.emit("data", { id: 3, value: "third" });

  yield* sleep(100);
});

Creating Reusable Stream Factories

Combine signals with resources to create reusable stream factories:

import type { Operation, Stream, Subscription, Signal } from "effection";
import { resource, createSignal, spawn, sleep, each } from "effection";
import { EventEmitter } from "events";

// A stream factory for any EventEmitter event
function eventsFrom<T>(
  emitter: EventEmitter,
  eventName: string,
): Stream<T, void> {
  return resource<Subscription<T, void>>(function* (provide) {
    const signal: Signal<T, void> = createSignal<T, void>();

    const handler = (value: T) => signal.send(value);
    emitter.on(eventName, handler);

    try {
      // Provide the subscription (the stream interface)
      const subscription: Subscription<T, void> = yield* signal;
      yield* provide(subscription);
    } finally {
      emitter.off(eventName, handler);
      signal.close();
    }
  });
}

When to Use Signals vs Channels

ScenarioUse
Both producer and consumer in EffectionChannel
Producer is a callback/external codeSignal
DOM/browser eventsSignal or on()
Node.js EventEmitterSignal
Communication between operationsChannel

Decision Flowchart

  1. Is the producer a callback, event handler, or external code?
    • Yes → Use Signal
    • No → Continue to #2
  2. Are both producer and consumer Effection operations?
    • Yes → Use Channel (preferred for operation-to-operation messaging)
    • No → Use Signal
  3. Do you need backpressure or synchronization between sender and receiver?
    • Yes → Use Channel (the yield* creates natural sync points)
    • No → Either works, but Signal is simpler

Key Takeaways

  1. Signals bridge callbacks to Effection - send() is a regular function
  2. Use signals for external events - DOM, EventEmitter, callbacks
  3. Channels for internal communication - between Effection operations
  4. Combine with resources - for clean setup/teardown of event listeners
  5. Built-in helpers - on() and once() work with any EventTarget
  • PreviousChannels
  • NextStreams