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
| Feature | Channel | Signal |
|---|---|---|
send() returns | Operation<void> | void |
| Can call in callbacks | No | Yes |
| Use inside operations | yield* channel.send() | signal.send() |
| Streaming consumption | Same | Same |
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
| Scenario | Use |
|---|---|
| Both producer and consumer in Effection | Channel |
| Producer is a callback/external code | Signal |
| DOM/browser events | Signal or on() |
| Node.js EventEmitter | Signal |
| Communication between operations | Channel |
Decision Flowchart
- Is the producer a callback, event handler, or external code?
- Yes → Use Signal
- No → Continue to #2
- Are both producer and consumer Effection operations?
- Yes → Use Channel (preferred for operation-to-operation messaging)
- No → Use Signal
- 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
- Yes → Use Channel (the
Key Takeaways
- Signals bridge callbacks to Effection -
send()is a regular function - Use signals for external events - DOM, EventEmitter, callbacks
- Channels for internal communication - between Effection operations
- Combine with resources - for clean setup/teardown of event listeners
- Built-in helpers -
on()andonce()work with any EventTarget