Skip to main content

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

ConstructorBehavior
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.

MethodReturnsUse 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)cancelFnYou need to cancel later but don't care about the result
unsafeRunAndForget(io)voidFire-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.