Dispatcher
A Dispatcher lets you run IO effects from outside the IO world —
from callbacks, event listeners, Future-based APIs, or any other context
where you can't use flatMap to sequence effects. It is the standard bridge
between purely functional IO code and the rest of the Dart ecosystem.
Like Supervisor, a Dispatcher is a Resource. Its lifecycle bounds the
effects submitted to it: on finalization, active effects are either canceled
or awaited, so nothing leaks.
Two execution modes
| Constructor | Behavior |
|---|---|
Dispatcher.parallel() | Each submitted effect runs as its own independent fiber concurrently |
Dispatcher.sequential() | Submitted effects are queued and run one at a time in FIFO order |
Both constructors accept an optional waitForAll parameter. When true,
finalization waits for all active (or queued) effects to complete rather than
canceling them.
Unsafe API
These methods are called unsafe because they step outside the IO
abstraction — they trigger execution immediately rather than building up a
description of effects.
| Method | Returns | Use when |
|---|---|---|
unsafeToFuture(io) | Future<A> | You need to await the result |
unsafeToFutureCancelable(io) | (Future<A>, cancelFn) | You need the result and the ability to cancel |
unsafeRunCancelable(io) | cancelFn | You need to cancel later but don't care about the result |
unsafeRunAndForget(io) | void | Fire-and-forget; result and errors are discarded |
Parallel dispatcher
Dispatcher.parallel is the most common choice. Each call to an unsafe method
starts a new fiber immediately, so effects run concurrently with one another
and with the rest of your program.
/// unsafeToFuture runs an IO and returns a Future that resolves to the result.
IO<int> parallelToFuture() => Dispatcher.parallel().use((dispatcher) {
final future = dispatcher.unsafeToFuture(IO.pure(42));
return IO.fromFutureF(() => future);
});
/// unsafeRunAndForget submits an IO for execution and discards the result.
/// Use when you only care about the side-effect, not the outcome.
IO<Unit> parallelFireAndForget() => Dispatcher.parallel().use((dispatcher) {
return IO
.delay(() {
dispatcher.unsafeRunAndForget(IO.print('background work'));
return Unit();
})
.productR(() => IO.sleep(10.milliseconds));
});
Sequential dispatcher
Dispatcher.sequential runs effects through a single worker fiber in the
order they were submitted. Use this when your effects must not interleave —
for example, when serializing writes to a shared resource.
/// A sequential Dispatcher serializes submitted effects in FIFO order —
/// each effect runs to completion before the next one starts.
IO<IList<int>> sequentialFifo() => IO.ref(nil<int>()).flatMap((log) {
return Dispatcher.sequential().use((dispatcher) {
return IO
.fromFutureF<Unit>(() async {
await dispatcher.unsafeToFuture(log.update((IList<int> l) => l.prepended(1)));
await dispatcher.unsafeToFuture(log.update((IList<int> l) => l.prepended(2)));
await dispatcher.unsafeToFuture(log.update((IList<int> l) => l.prepended(3)));
return Unit();
})
.productR(() => log.value());
});
});
Real-world example: bridging a callback to a Queue
The most instructive use of Dispatcher is wiring a callback-based interface
to a Queue. Consider an SDK that fires onMessage from outside the IO
world — a platform event, a native library callback, or any other impure boundary.
The naive approach is to call queue.offer(msg) directly inside the callback.
This compiles, but IO is lazy — calling offer only constructs a
description of the effect; it never executes it. The queue stays empty.
Dispatcher solves this: unsafeRunAndForget is the one place where an IO
effect is actually run from a plain Dart function. Wrapping the offer in it
delivers the message into the queue, where the rest of the IO program can
consume it with queue.take().
/// Simulates an impure interface — an SDK callback, platform event, or any
/// other context that calls [onMessage] from outside the IO world.
void initSdk(void Function(String) onMessage) => onMessage('hello from sdk');
/// WITHOUT a Dispatcher: IO is lazy — queue.offer() returns an `IO<Unit>`
/// description but never executes it. The message is lost.
IO<Unit> withoutDispatcher() => Queue.unbounded<String>().flatMap((queue) {
initSdk((msg) {
queue.offer(msg); // returns IO<Unit> and discards it — nothing runs
});
return queue.tryTake().flatMap((oa) => IO.print(oa.fold(() => 'nothing :(', (v) => v)));
// prints: nothing :(
});
/// WITH a Dispatcher: unsafeRunAndForget executes the IO immediately,
/// placing the message in the queue where the IO program can consume it.
IO<Unit> withDispatcher() => Dispatcher.sequential().use((dispatcher) {
return Queue.unbounded<String>().flatMap((queue) {
initSdk((msg) => dispatcher.unsafeRunAndForget(queue.offer(msg)));
return queue.take().flatMap((msg) => IO.print(msg));
// prints: hello from sdk
});
});
The same pattern applies to any impure boundary — button handlers, timers,
platform channels, or third-party SDK callbacks. Give each one a Dispatcher
reference and it gains a safe, scoped way to trigger IO effects.