Actions
Most JavaScript APIs speak callback. setTimeout, event listeners, XHR, WebSockets—they all want you to pass a function that they'll call "later."
But Effection speaks generator. How do we translate between these two languages?
The answer is actions—the interpreters that let callbacks talk to operations.
The Promise Constructor Pattern
You've probably written this pattern before:
function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
The Promise constructor takes an "executor" function that receives resolve and reject. When your callback fires, you call resolve() to complete the promise.
The Action Constructor
Effection's action() works similarly, but with one critical difference:
import type { Operation } from "effection";
import { action } from "effection";
function sleep(ms: number): Operation<void> {
return action((resolve) => {
const timeoutId = setTimeout(resolve, ms);
return () => clearTimeout(timeoutId); // Cleanup function - required!
});
}
Actions must return a cleanup function. This is the magic that prevents leaked effects!
The Cleanup Function
The cleanup function is called when:
- The action resolves (via
resolve()) - The action rejects (via
reject()) - The action is halted (parent scope ends)
import type { Operation } from "effection";
import { main, action, race } from "effection";
function sleep(ms: number): Operation<void> {
return action((resolve) => {
console.log(`Starting ${ms}ms timer`);
const timeoutId = setTimeout(() => {
console.log(`${ms}ms timer completed`);
resolve();
}, ms);
return () => {
console.log(`Cleaning up ${ms}ms timer`);
clearTimeout(timeoutId);
};
});
}
await main(function* () {
yield* race([sleep(10), sleep(1000)]);
console.log("Race done!");
});
Output:
Starting 10ms timer
Starting 1000ms timer
10ms timer completed
Cleaning up 10ms timer
Cleaning up 1000ms timer
Race done!
Both timers are cleaned up! The 1000ms timer is halted when the 10ms timer wins.
A More Complex Example: Fetch with AbortController
Here's how to wrap the native fetch API with proper cancellation:
import type { Operation } from "effection";
import { main, action, race } from "effection";
function* fetchUrl(url: string): Operation<string> {
return yield* action<string>((resolve, reject) => {
const controller = new AbortController();
console.log(`Starting request to ${url}`);
fetch(url, { signal: controller.signal })
.then((response) => response.text())
.then((text) => {
console.log(`Completed request to ${url}`);
resolve(text);
})
.catch((err) => {
if (err.name !== "AbortError") {
reject(err);
}
});
return () => {
console.log(`Aborting request to ${url}`);
controller.abort();
};
});
}
// If you race two fetch operations, the loser's HTTP request is actually cancelled!
await main(function* () {
const result: string = yield* race([
fetchUrl("https://httpbin.org/delay/1"),
fetchUrl("https://httpbin.org/delay/2"),
]);
console.log("Winner:", result.slice(0, 100) + "...");
});
The Action API
The action() function signature:
function action<T>(
executor: (
resolve: (value: T) => void,
reject: (error: Error) => void,
) => () => void,
): Operation<T>;
resolve(value)- Complete the action successfully with a valuereject(error)- Complete the action with an error- Return value - A cleanup function (required!)
Important: The executor is a regular function, not a generator function!
Using action() with Event Listeners
Here's how to wait for a single event:
import type { Operation } from "effection";
import { action } from "effection";
function once<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
eventName: K,
): Operation<HTMLElementEventMap[K]> {
return action((resolve) => {
const handler = (event: HTMLElementEventMap[K]) => resolve(event);
target.addEventListener(eventName, handler);
return () => target.removeEventListener(eventName, handler);
});
}
// Usage (in a browser context):
// await main(function*() {
// console.log('Click the button...');
// const event = yield* once(button, 'click');
// console.log('Button clicked!', event);
// });
If the operation is halted before the click, the event listener is removed.
Node.js Event Example
Here's a more practical Node.js example:
import type { Operation } from "effection";
import { main, action, sleep } from "effection";
import { EventEmitter } from "events";
function once<T>(emitter: EventEmitter, eventName: string): Operation<T> {
return action<T>((resolve, reject) => {
const handler = (value: T) => resolve(value);
const errorHandler = (error: Error) => reject(error);
emitter.on(eventName, handler);
emitter.on("error", errorHandler);
return () => {
emitter.off(eventName, handler);
emitter.off("error", errorHandler);
};
});
}
// Demo showing "once" only captures first event
await main(function* () {
const emitter = new EventEmitter();
// Schedule multiple events
setTimeout(() => {
console.log("Emitting: first");
emitter.emit("data", { message: "first" });
}, 100);
setTimeout(() => {
console.log("Emitting: second");
emitter.emit("data", { message: "second" });
}, 200);
setTimeout(() => {
console.log("Emitting: third");
emitter.emit("data", { message: "third" });
}, 300);
// once() only captures the first event, then cleans up the listener
const data: { message: string } = yield* once(emitter, "data");
console.log("Received:", data.message);
// Wait to show other events are emitted but ignored
yield* sleep(400);
console.log("Done - only captured first event");
});
Output:
Emitting: first
Received: first
Emitting: second
Emitting: third
Done - only captured first event
Error Handling in Actions
Use reject() to signal errors:
import type { Operation } from "effection";
import { action } from "effection";
function loadImage(url: string): Operation<HTMLImageElement> {
return action<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load: ${url}`));
img.src = url;
return () => {
img.src = ""; // Cancel loading
};
});
}
// The error propagates like any other error in Effection:
// await main(function*() {
// try {
// const img = yield* loadImage('https://example.com/missing.png');
// } catch (error) {
// console.log('Image failed to load:', error.message);
// }
// });
When to Use Actions
Use action() when you need to:
- Wrap callback-based APIs (setTimeout, events, XHR)
- Ensure cleanup always happens (remove listeners, abort requests)
- Bridge external callbacks into Effection (one-time events)
For ongoing streams of events (multiple clicks, WebSocket messages), you'll want Channels and Signals - covered later in this tutorial.
Key Takeaways
Actions are interpreters between callback-land and generator-land:
action()is like the Promise constructor - but with mandatory cleanup (the interpreter always cleans up after itself)- Always return a cleanup function - this is what prevents leaked effects
- Cleanup runs in all cases - resolve, reject, or halt
- The executor is a regular function - not a generator (it speaks callback)
- Actions are for one-time events - use Signals for ongoing streams