Futures
We mentioned in the introduction that Effection is fundamentally
synchronous. What we mean by this is that any Task
could complete
synchronously. Most of the time, tasks do take a while to complete, but
sometimes they do not, and can in fact complete instantaneously.
This is different from Promises and async/await
. The Promise specification
requires that promises always complete asynchronously. And while they might
complete very quickly, they always have to release the event loop to do so.
There are good reasons for this behavior in promises, mostly related to error handling, but requiring the event loop to be released also has downsides.
Let's look at an example of where releasing the event loop might be an issue.
Imagine you are writing an application for the browser and you want to handle
click
events. We would like to intercept the click event and prevent the
default behavior. We could write our code like this:
import { main, once } from 'effection';
main(function*() {
let event = yield once(document.body, 'click');
event.preventDefault();
console.log('you clicked!');
});
If our yield
point released the event loop after the click occurred, then the
default action has already happened by the time we call preventDefault
. This
is no good, we need once
to resolve synchronously.
A simplified promise based implementation of once
could look like this:
export function once(source, eventName) {
return (task) => {
return new Promise((resolve) => {
source.addEventListener(eventName, resolve);
task.finally(() => {
source.removeEventListener(eventName, resolve);
});
});
}
};
Since the returned Promise
always resolves asynchronously, we end up releasing
the event loop before we're able to call preventDefault
. This will not work.
Implementing once
using a Generator
is possible by writing the generator
implementation manually, but it is very awkward, and getting it right is very
difficult.
To solve this problem, we can use a Future.
A Future is similar to a Promise, and it can also act like a Promise when
needed. But a Future can also be consumed using consume
, which resolves
completely synchronously.
Here is how we can implement once
using a Future:
import { createFuture } from 'effection';
export function once(source, eventName) {
return (task) => {
let { future, produce } = createFuture();
let listener = (value) => produce({ state: 'completed', value });
source.addEventListener(eventName, listener);
task.consume(() => {
source.removeEventListener(eventName, listener);
});
return future;
}
};
Note that a Task
is also a Future, and so we can consume it using consume
.
Error handling
One key differences between futures and promises is that futures are not well
behaved when it comes to error handling. You should make sure never to throw an
error in consume
.
Futures are low-level and exist to implement some operations which would otherwise be difficult to implement with Effection, they are not meant to be general-purpose in the same way that promises are. Many operations can be implemented using promises just fine, and if you can do so you should prefer Promises over Futures, since they are easier to reason about, and easier to compose.