Skip to content

solo and bloc, side by side

This guide compares solo with Bloc and Cubit through ten controller scenarios. Each section states the required behavior, shows implementations, and explains what the library handles and what remains application code.

Bloc processes events registered with on<E>. A transformer determines how events in that registration are scheduled. Cubit exposes methods that update state directly; it does not provide an event queue.

In solo, a controller method normally creates a Job<T> and adds it to the controller’s queue. Root jobs run one at a time. The caller can await job.value for a value or exception, or job.done for a Done, Failed or Cancelled outcome. Queue policies apply to individual submissions.

The examples introduce other solo APIs where they are used. The README provides a complete introduction and the detailed contracts.

Examples use separate application models: a Ready state in the player example is not the sensor’s Ready. Supporting state classes, event classes and fake APIs are supplied by the runnable documentation harness. The examples were checked with bloc 9.2.1, bloc_concurrency 0.3.0 and the local solo 0.2.0 source. Traces describe those runs, not timing guarantees for arbitrary devices. The harness extracts the code from this document; it is available as tool/doc_snippets.py in the repository.

The main API correspondences, for a reader who knows bloc:

bloc solo
Bloc<E, S>, Cubit<S> Solo<S>, SoloListenable<S>
Event class, on<E>, add(E()) Method returning Job<T>
EventTransformer Policy on a job submission
emit(next) ctx.emit(next)
if (emit.isDone) return; Cancellation checkpoints such as ctx.wait and ctx.check
emit.onEach, emit.forEach ctx.each(stream, onData)
state, stream state, stream
BlocObserver SoloObserver
BlocBuilder, BlocSelector ValueListenableBuilder; selection is application code
BlocListener for an operation’s result Await that operation’s job.done
BlocProvider Your chosen ownership or dependency mechanism
close() close()
blocTest test and an awaited job outcome

sequential, droppable and restartable correspond to Policy.sequential, Policy.droppable and Policy.restart. Policy.replace removes queued work while leaving the running job alone. There is no concurrent root-job policy; use children or separate controllers for independent concurrent work.

A notes controller uploads a note and refreshes the list from the server. If refresh starts before upload completes, its response can contain the old list. Publishing that response after upload would remove the new note from local state, even if both handlers read the latest local state. The requirement is to serialize the complete operations, including I/O.

The default event scheduling is concurrent. To serialize both event types, register one handler for their common type and use sequential():

class NotesBloc extends Bloc<NotesEvent, NotesState> {
final Api _api;
NotesBloc(this._api) : super(const NotesState()) {
on<NotesEvent>((e, emit) async {
switch (e) {
case UploadNote(:final note):
emit(state.copyWith(uploading: true));
await _api.upload(note);
emit(
state.copyWith(notes: [...state.notes, note], uploading: false),
);
case RefreshList():
final serverNotes = await _api.list();
emit(state.copyWith(notes: serverNotes));
}
}, transformer: sequential());
}
}

The measured final state is NotesState([n0, n1], uploading: false). The upload completes before refresh reads the server. One registration also means one transformer for all commands handled by that registration.

Putting sequential() on two separate on<E> registrations does not serialize them with each other. In the same scenario that arrangement ends at NotesState([n0], uploading: false). Every operation requiring the shared ordering must therefore use the same registration. The per-handler ordering discussion describes this distinction.

There is a separate snapshot issue in emit(state.copyWith(notes: await _api.list())): Dart evaluates the receiver state before awaiting the argument. The concurrent test of this variant ends at NotesState([n0], uploading: true), overwriting both the notes and upload flag. Reading state after the await avoids that snapshot problem, but does not serialize the server operations.

Methods submitted to the same controller share its root-job queue:

final class NotesController extends Solo<NotesState> {
final Api _api;
NotesController(this._api) : super(const NotesState());
Job<void> upload(Note note) => run<NotesState, void>(
key: 'upload',
(ctx) async {
ctx.emit(ctx.state.copyWith(uploading: true));
await ctx.join(() => _api.upload(note));
final merged = [...ctx.state.notes, note];
ctx.emit(ctx.state.copyWith(notes: merged, uploading: false));
},
);
Job<void> refresh() => run<NotesState, void>(
key: 'refresh',
(ctx) async {
final serverNotes = await ctx.wait(_api.list);
ctx.emit(ctx.state.copyWith(notes: serverNotes));
},
);
}

ctx.join(action) calls the operation and waits for it. After a successful response it checks cancellation before returning the result. Upload uses join so the next root job cannot read the server while the upload is still in progress. Refresh uses ctx.wait, which can stop waiting on cancellation because this example allows its read result to be abandoned.

The final state is also NotesState([n0, n1], uploading: false). Adding another queued method preserves the shared ordering. This guarantee covers root jobs; child jobs can run inside a parent, and independent device changes have a separate path described in section 8.

Use context waiting methods inside job bodies to check cancellation and state rules. A plain await still keeps the body and queue occupied but does not react to cancellation. Work started without awaiting it can outlive the job; it must not be assumed to finish before close().

2. An observer fails during a state update

Section titled “2. An observer fails during a state update”

A recorder starts its native recording, publishes Recording, then arms a level meter. A telemetry observer throws while processing the update. The recorder operation should finish independently of telemetry reporting.

Both use BlocBase.emit. It calls onChange before updating state, and an exception from that call is passed to onError and rethrown:

class RecorderBloc extends Bloc<StartRecording, RecorderState> {
final Recorder _recorder;
final Journal _journal;
RecorderBloc(this._recorder, this._journal) : super(const Idle()) {
on<StartRecording>((e, emit) async {
await _recorder.start();
emit(const Recording());
_recorder.armMeter();
});
}
@override
void onChange(Change<RecorderState> change) {
super.onChange(change);
_journal.note('${change.nextState}');
}
}
class TelemetryObserver extends BlocObserver {
final Telemetry _telemetry;
TelemetryObserver(this._telemetry);
@override
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
super.onChange(bloc, change);
_telemetry.send(change.nextState);
}
}

In this run the device starts, but state remains Idle. The level meter is never armed. The local journal is empty because its write follows the failing super.onChange call. The handler reports telemetry unavailable. The same observer failure in a Cubit method reaches the method’s caller.

Catch telemetry errors within the observer to prevent them from interrupting the update:

class GuardedTelemetryObserver extends BlocObserver {
final Telemetry _telemetry;
final Log _fallback;
GuardedTelemetryObserver(this._telemetry, this._fallback);
@override
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
super.onChange(bloc, change);
try {
_telemetry.send(change.nextState);
} on Object catch (error, stackTrace) {
_fallback.write(error, stackTrace);
}
}
}

The guarded run reaches Recording, records [native start, arm meter] and writes [Recording] to the journal. The fallback logger receives the telemetry error. This approach works when every relevant observation callback handles its failures and its fallback does not throw. Overriding onError alone does not prevent emit from rethrowing.

The observer and controller hooks are invoked independently. Each hook’s exception is reported to the current Dart zone:

final class RecorderController extends Solo<RecorderState> {
final Recorder _recorder;
final Journal _journal;
RecorderController(this._recorder, this._journal) : super(const Idle());
Job<void> start() => run<RecorderState, void>(
key: 'start',
(ctx) async {
await ctx.join(_recorder.start);
ctx.emit(const Recording());
_recorder.armMeter();
},
);
@override
void onChange(RecorderState previous, RecorderState current) =>
_journal.note('$current');
}
final class TelemetryObserver extends SoloObserver {
final Telemetry _telemetry;
TelemetryObserver(this._telemetry);
@override
void onChange(SoloBase<Object> solo, Object previous, Object current) =>
_telemetry.send(current);
}

SoloObserver receives SoloBase<Object> because it observes controllers with different state types. The controller’s own hook uses its specific state type.

With the throwing observer, the run still reaches Recording, arms the meter, records [Recording] locally and finishes with Done(null). The telemetry error is reported to the zone. The test harness handles it with runZonedGuarded; an unhandled zone error can still terminate an application. Hook isolation preserves the operation’s control flow, while the application remains responsible for reporting errors.

A user sends a chat message and leaves the screen before the reply arrives. The controller must prevent the old operation from updating a closed screen or scheduling follow-up work after closure.

Closing behavior depends on the transformer in these versions. With sequential(), close() waits for the running handler, which can still emit while closure is pending. With the default transformer, concurrent, droppable or restartable, the measured close returns before the body finishes and the cancelled emitter ignores subsequent writes. Neither case interrupts the API call or the rest of the handler body.

This sequential handler checks closure after its await:

class ChatBloc extends Bloc<ChatEvent, ChatState> {
final Api _api;
ChatBloc(this._api) : super(const ChatState()) {
on<SendMessage>((e, emit) async {
final reply = await _api.send(e.text);
if (isClosed) return;
emit(state.withReply(reply));
add(const MarkReplyRead());
}, transformer: sequential());
on<MarkReplyRead>(
(e, emit) => _api.markRead(),
transformer: sequential(),
);
}
}

The guard prevents both the reply update and the MarkReplyRead event. Closing still waits for the API call and handler to return. The separate MarkReplyRead registration is safe for this example because it does not write shared state; otherwise section 1’s ordering concern would apply.

add after close throws StateError, so callers that can submit late events need their own handling. A Cubit method also continues after closure, but its direct emit then throws rather than using a cancelled handler emitter.

Another case is an unawaited future that emits after its handler completed normally, while the Bloc is still open. This triggers a debug assertion; with assertions disabled, the measured late write changes state. The completed-handler issue discusses this lifetime constraint. Cancellation support is also discussed in the async-operation proposal.

close() stops accepting jobs, cancels queued jobs, requests cancellation of the running job and waits for its completion, including cleanup:

final class ChatController extends Solo<ChatState> {
final Api _api;
ChatController(this._api) : super(const ChatState());
Job<void> send(String text) => run<ChatState, void>(
key: 'send',
(ctx) async {
final reply = await ctx.wait(() => _api.send(text));
ctx.emit(ctx.state.withReply(reply));
markReplyRead();
},
);
Job<void> markReplyRead() =>
run<ChatState, void>((ctx) => ctx.wait(_api.markRead));
}
Future<void> onScreenClosed(ChatController chat) async {
await chat.close();
chat.send('bye'); // a job already finished with Cancelled(closed)
}

In this body, ctx.wait throws Cancelled when closing cancels the job. The reply update and markReplyRead() call are not reached. That method would submit a separate root job through the controller’s run; it is not a child of send.

With a plain await instead, closing would wait for the API response. The later ctx.emit would still reject the cancelled job. A captured context used after job completion throws StateError in both debug and release builds. Calls made after closure return jobs already completed with Cancelled(closed), so the call site needs no isClosed guard.

Cancellation can also happen while the controller remains open. Consider a refresh indicator with two states, Initial and Loading. A StartRefresh event begins work; CancelRefresh cancels that handler through the same restartable() registration. This example tracks only whether refresh is active; the API returns no data to display.

In Bloc, putting the reset in the old handler’s finally is insufficient:

class RefreshBloc extends Bloc<RefreshEvent, RefreshState> {
final RefreshApi _api;
RefreshBloc(this._api) : super(const Initial()) {
on<RefreshEvent>((event, emit) async {
if (event is CancelRefresh) return;
emit(const Loading());
try {
await _api.refresh();
} finally {
emit(const Initial());
}
}, transformer: restartable());
}
}

After StartRefresh, state is Loading. CancelRefresh replaces that handler but publishes no new state. The API future continues running. When it eventually completes, finally calls the old, cancelled emitter, which ignores Initial. The state therefore remains Loading even after the API call ends. Cancellation itself did not throw into the old body.

The Bloc solution is to publish the reset from the active cancellation handler. Replace the CancelRefresh branch with:

if (event is CancelRefresh) {
emit(const Initial());
return;
}

Now state becomes Initial when the cancel event is handled. The old handler’s later emit is still ignored and cannot overwrite a newer refresh. With restartable, all event types in this registration can replace the current handler; a separate on<CancelRefresh> registration would not cancel StartRefresh by itself.

In solo, attach the reset to the operation’s final outcome:

final class RefreshController extends Solo<RefreshState> {
final RefreshApi _api;
RefreshController(this._api) : super(const Initial());
Job<void> refresh() => run<RefreshState, void>(
key: 'refresh',
policy: Policy.restart,
onError: (state, error, stackTrace) => const Initial(),
onCancel: (state, cancelled) => const Initial(),
(ctx) async {
ctx.emit(const Loading());
await ctx.wait(_api.refresh);
ctx.emit(const Initial());
},
);
}

Call controller.refresh() and retain its Job; await job.cancel() waits for cancellation and cleanup. ctx.wait ends the wait for the API, and onCancel returns Initial before the queue proceeds. A successful refresh publishes Initial from the body; onError resets the indicator on failure while the Job still reports Failed.

The timing differs: Bloc’s cancel-event handler updates state when it runs, while solo’s state handler runs after the cancelled Job’s cleanup. Neither example stops the API operation itself. If an independent external state makes the solo Job invalid, its final state handlers are skipped; section 8 explains external state and the README describes handler rules.

4. Restarting one operation within a shared queue

Section titled “4. Restarting one operation within a shared queue”

A player must run play, pause and seek one at a time. During a slider drag, queued seek positions become obsolete and an active seek should stop before its replacement starts. Play and pause retain their order. The player API in this example accepts a token and returns when cancelled.

A separate on<Seek> with restartable() can overlap play and pause handlers. To keep all commands serialized, this implementation uses one sequential() registration and tracks the latest seek and active token:

class PlayerBloc extends Bloc<PlayerCommand, PlayerState> {
final Player _player;
Duration? _newestSeek;
CancelToken? _seeking;
PlayerBloc(this._player) : super(const PlayerState()) {
on<PlayerCommand>((command, emit) async {
switch (command) {
case Play():
await _player.play();
case Pause():
await _player.pause();
case Seek(:final position):
if (position != _newestSeek) return;
final token = CancelToken();
_seeking = token;
await _player.seek(position, cancelToken: token);
_seeking = null;
if (token.cancelled) return;
emit(PlayerState(position: position));
}
}, transformer: sequential());
}
@override
void onEvent(PlayerCommand event) {
if (event is Seek) {
_newestSeek = event.position;
_seeking?.cancel();
}
super.onEvent(event);
}
}

For positions 1, 2, 3 queued together, the device receives [play, seek 3, pause]. If seek 1 is already active when seek 3 arrives, the trace is [seek 1 start, seek 1 stopped, seek 3 start, seek 3 end]. onEvent receives the new request synchronously and cancels the token; the sequential handler waits for the player to return before proceeding.

Comparing position values is insufficient when the drag repeats a value. Input 1, 2, 1 produces [play, seek 1, seek 1, pause]. Identifying the latest event with a generation counter instead of its position fixes that case.

The application maintains the latest-request identity, active token and post-await check. Each command requiring different replacement behavior needs corresponding logic within the shared handler. add does not return the outcome of a skipped or interrupted seek.

Queue policy belongs to each job submission. The fragment shows pause and seek; play follows the same sequential pattern as pause. Ready is the working state type accepted by these player jobs:

enum PlayerKey { play, pause, seek }
final class PlayerController extends Solo<PlayerState> {
final Player _player;
PlayerController(this._player) : super(Ready());
Job<void> pause() => run<Ready, void>(
key: PlayerKey.pause,
(ctx) async {
await ctx.join(_player.pause);
ctx.emit(ctx.state.copyWith(playing: false));
},
);
Job<void> seek(Duration position) => run<Ready, void>(
key: PlayerKey.seek,
policy: Policy.restart,
(ctx) async {
final token = CancelToken();
ctx.onCancel(token.cancel);
await ctx.join(() => _player.seek(position, cancelToken: token));
ctx.emit(ctx.state.copyWith(position: position));
},
);
}

Policy.restart removes cancellable queued seeks with the same key and requests cancellation of the active seek. ctx.onCancel forwards that request to the player immediately. ctx.join waits for the operation to return before the current job can finish and the replacement can start. If the operation fails after cancellation, its error reaches the body; the job’s outcome still remains cancelled.

The observed traces match the successful Bloc cases above. Pause and play remain in the same queue with their default sequential policy. The token is local to the seek body, and callers can inspect each seek’s outcome, including Cancelled(manual) for a replaced request.

A map exposes moveTo and setZoom. Callers need typed arguments, and rapid drag updates should not make the map finish at an older position.

Cubit directly supports methods returning futures:

class MapCubit extends Cubit<MapState> {
final MapApi _map;
MapCubit(this._map) : super(const MapState());
Future<void> moveTo(Point<double> point) async {
await _map.moveTo(point);
emit(state.copyWith(center: point));
}
Future<void> setZoom(double value) async {
await _map.setZoom(value);
emit(state.copyWith(zoom: value));
}
}

These methods do not serialize calls. With unequal native-call durations, the trace is [moveTo 1 start, moveTo 2 start, moveTo 3 start, moveTo 3 end, moveTo 2 end, moveTo 1 end]. State ends at MapState(1, z1), reflecting the oldest request because it finished last.

A future chain can serialize calls, with additional tracking to discard obsolete requests. The application must also decide how closure waits for or invalidates that chain; Cubit does not manage it automatically.

An event can itself be a function. One sequential registration runs those functions, while public methods provide the typed call interface:

typedef MapCommand = Future<void> Function(Emitter<MapState>);
class CommandMapBloc extends Bloc<MapCommand, MapState> {
final MapApi _map;
CommandMapBloc(this._map) : super(const MapState()) {
on<MapCommand>(
(command, emit) => command(emit),
transformer: sequential(),
);
}
void moveTo(Point<double> point) => add((emit) async {
await _map.moveTo(point);
emit(state.copyWith(center: point));
});
void setZoom(double value) => add((emit) async {
await _map.setZoom(value);
emit(state.copyWith(zoom: value));
});
}

The calls now run in order and end at MapState(3, z4). This provides typed methods and shared ordering without a separate event class per method. All commands still use one transformer, and the shown methods return void. Returning a result requires an additional mechanism, as in section 7.

Each method returns a Job and specifies its replacement policy:

enum MapKey { moveTo, setZoom }
final class MapController extends Solo<MapState> {
final MapApi _map;
MapController(this._map) : super(const MapState());
Job<void> moveTo(Point<double> point) => run<MapState, void>(
key: MapKey.moveTo,
policy: Policy.restart,
(ctx) async {
final token = CancelToken();
ctx.onCancel(token.cancel);
await ctx.join(() => _map.moveTo(point, cancelToken: token));
ctx.emit(ctx.state.copyWith(center: point));
},
);
Job<void> setZoom(double value) => run<MapState, void>(
key: MapKey.setZoom,
policy: Policy.restart,
(ctx) async {
final token = CancelToken();
ctx.onCancel(token.cancel);
await ctx.join(() => _map.setZoom(value, cancelToken: token));
ctx.emit(ctx.state.copyWith(zoom: value));
},
);
}
void onMapDrag(MapController map, Point<double> point) => map.moveTo(point);

The restart trace is [moveTo 1 start, moveTo 1 stopped, moveTo 3 start, moveTo 3 end]. The middle request does not start; the first stops before the latest one begins. Both the map and MapState(3, z4) reflect the latest request. Callers may await job.value or job.done to wait for the operation, or omit waiting. The returned Job itself is not a Future; its error-handling rules are described in the README.

A BLE screen queues connect, battery and signal reads, rename and disconnect. When the screen closes, pending reads should be discarded, while the requested rename must still complete before disconnect.

One sequential() registration orders all device commands. Its pending events cannot be enumerated or removed through the Bloc API, so the handler checks whether a read is still relevant.

A single leaving flag fails if the screen reopens before old events drain: clearing the flag for the new screen makes an old read valid again. This version records the screen generation on each event:

class DeviceBloc extends Bloc<DeviceEvent, DeviceState> {
final Ble _ble;
final _stampOf = Expando<int>();
int _screen = 0;
DeviceBloc(this._ble) : super(const DeviceState()) {
on<DeviceEvent>((e, emit) async {
switch (e) {
case Connect():
await _ble.connect();
emit(state.copyWith(online: true));
case ReadBattery():
if (_stampOf[e] != _screen) return;
emit(state.copyWith(battery: await _ble.battery()));
case ReadSignal():
if (_stampOf[e] != _screen) return;
emit(state.copyWith(signal: await _ble.signal()));
case Rename(:final name):
await _ble.rename(name);
case Disconnect():
await _ble.disconnect();
emit(const DeviceState());
}
}, transformer: sequential());
}
@override
void onEvent(DeviceEvent event) {
if (event is Disconnect) _screen++;
_stampOf[event] = _screen;
super.onEvent(event);
}
}

The device receives [connect, rename kitchen, disconnect]. If the screen reopens before pending work drains, the old read is skipped and the new one runs: [connect, disconnect, connect, battery].

The generation records are application state associated with the queue. Events still reach the handler, which must check each discardable command. The Expando scheme also requires distinct event objects. Reusing a canonical const event overwrites its earlier stamp; the measured sequence then includes the stale read: [connect, battery, disconnect, connect, battery]. add provides no result indicating that a read was skipped.

The controller can remove matching jobs directly from its queue:

enum DeviceKey { connect, readBattery, readSignal, rename, disconnect }
final class DeviceController extends Solo<DeviceState> {
final Ble _ble;
DeviceController(this._ble) : super(const Offline());
Job<void> disconnect() {
queue.removeWhere(
(job) =>
job.key == DeviceKey.readBattery || job.key == DeviceKey.readSignal,
);
return run<Connected, void>(
key: DeviceKey.disconnect,
(ctx) async {
await ctx.join(_ble.disconnect);
ctx.emit(const Offline());
},
);
}
}

The hardware trace is the same, but the removal happens when disconnect is called. The removed read jobs complete with Cancelled(manual); their callers can observe that result. Rename stays queued, and disconnect runs after it. removeWhere does not affect an already running connect. Jobs marked cancellable: false require force: true for queue removal.

A checkout can be called by both a Pay button and a platform request. The platform handler must return the result of its order. Concurrent requests for the same order should share one payment; distinct orders should execute sequentially.

Watching Paid through a BlocListener can serve a screen’s navigation, but a function answering a platform request needs a result associated with that request. Bloc.add returns void; the awaiting-events discussion covers this use case.

Carry a completer on the event and expose a method returning its future. An in-flight map makes duplicate callers share the same result:

class Pay extends CheckoutEvent {
final Order order;
final Completer<Receipt> result;
Pay(this.order) : result = Completer<Receipt>();
}
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
final Api _api;
final _inFlight = <String, Completer<Receipt>>{};
CheckoutBloc(this._api) : super(Cart()) {
on<Pay>((e, emit) async {
try {
emit(Paying());
final receipt = await _api.pay(e.order);
e.result.complete(receipt);
emit(Paid(receipt));
} on Object catch (error, stackTrace) {
if (!e.result.isCompleted) {
e.result.completeError(error, stackTrace);
}
emit(PaymentFailed(error));
} finally {
_inFlight.remove(e.order.id);
}
}, transformer: sequential());
}
Future<Receipt> pay(Order order) {
final running = _inFlight[order.id];
if (running != null) return running.future;
final event = Pay(order);
_inFlight[order.id] = event.result;
try {
add(event);
} on Object {
_inFlight.remove(order.id);
rethrow;
}
return event.result.future;
}
}

await bloc.pay(order) now returns a receipt. Three calls for two orders make two API calls, and both callers for one order receive its receipt.

The completer must finish on every path. The add catch removes the map entry when a closed Bloc refuses the event. isCompleted prevents double completion if an emit throws after the receipt has been delivered. Completing the receipt before publishing Paid prevents an observer error from replacing an already obtained receipt. droppable() alone would not implement this sharing: a dropped event would leave its completer pending.

A Cubit method can return a receipt directly, but overlapping calls are not deduplicated or serialized automatically. This version adds an in-flight map and a future chain:

class CheckoutCubit extends Cubit<CheckoutState> {
final Api _api;
final _inFlight = <String, Future<Receipt>>{};
Future<void> _tail = Future.value();
CheckoutCubit(this._api) : super(Cart());
Future<Receipt> pay(Order order) {
final running = _inFlight[order.id];
if (running != null) {
return running;
}
final turn = Completer<void>();
final before = _tail;
_tail = turn.future;
final result = before.then((_) => _pay(order)).whenComplete(turn.complete);
_inFlight[order.id] = result;
return result;
}
Future<Receipt> _pay(Order order) async {
try {
emit(Paying());
final receipt = await _api.pay(order);
emit(Paid(receipt));
return receipt;
} finally {
_inFlight.remove(order.id);
}
}
}

It also makes two API calls for three requests covering two orders. Its closure handling remains incomplete: Cubit does not wait for this custom chain. In the measured close-during-payment case, the charge succeeds, but the later emit(Paid(...)) throws Bad state: Cannot emit new states after calling close, so the caller receives that error instead of the receipt. A production implementation must coordinate closure with its payment chain and result delivery.

Use the order identity as the key and return the job directly:

final class CheckoutController extends Solo<CheckoutState> {
final Api _api;
CheckoutController(this._api) : super(const Cart());
Job<Receipt> pay(Order order) => run<CheckoutState, Receipt>(
key: ('pay', order.id),
policy: Policy.droppable,
cancellable: false,
(ctx) async {
ctx.emit(const Paying());
final receipt = await ctx.join(() => _api.pay(order));
ctx.emit(Paid(receipt));
return receipt;
},
);
}

The platform handler can inspect that Job’s outcome:

Future<Map<String, Object?>> handlePayRequest(
CheckoutController checkout,
Order order,
) async {
switch (await checkout.pay(order).done) {
case Done(:final value):
return {'paid': true, 'receipt': value.id};
case Cancelled(:final reason):
return {'paid': false, 'cancelled': reason.name};
case Failed(:final error):
return {'paid': false, 'error': '$error'};
}
}

Policy.droppable returns the existing queued or running Job for the same order key. Both callers share its receipt, while another order creates another Job. The measured three requests again make two API calls.

cancellable: false protects the complete payment operation from ordinary cancellation, including controller closure once it is running. join waits for the API result, and the body publishes Paid before completing with the receipt. close() waits for that completion. API failures still produce Failed; a failure state can be supplied with run(onError: ...).

This job accepts the base CheckoutState and has no keepWhile restriction. State rules can cancel even a non-cancellable Job, so a narrower type would allow cancellation after a charge was sent but before it was recorded. No waiting method can retract a charge from an API that provides no cancellation mechanism.

ctx.uncancellable has a different purpose: it holds ordinary cancellation for one step, then applies the request afterwards. It does not guarantee a successful Job outcome for a payment that completed during that step. It is unnecessary for the non-cancellable job shown here.

The flag also refuses manual cancellation while the payment is queued. If users must be able to cancel before charging starts, model that admission decision separately. close() and queue.clear(force: true) can still discard a queued payment without charging, and a submission after close also never starts. These cases explain the platform handler’s Cancelled branch.

Sharing an in-memory Job only deduplicates concurrent calls to this controller. Payment retries across process restarts or network failures also require an idempotent payment API; neither example provides that.

8. Reacting to an independent external state change

Section titled “8. Reacting to an independent external state change”

A sensor reports a hardware failure while calibration is waiting for a sample. The controller must reflect Broken promptly and prevent the calibration from later publishing Calibrated over it.

Bloc’s direct emit is marked @visibleForTesting and documented for internal use. The listener can instead add a HardwareFailed event. Give it a separate registration so it does not wait behind calibration:

class SensorBloc extends Bloc<SensorEvent, SensorState> {
final Sensor _hw;
SensorBloc(this._hw) : super(Ready()) {
_hw.onError = (error) => add(HardwareFailed(error));
on<HardwareFailed>((e, emit) => emit(Broken(e.error)));
on<Calibrate>((e, emit) async {
if (state is! Ready) return;
await _hw.zero();
if (state is! Ready) return;
await _hw.sample();
if (state is! Ready) return;
emit(Calibrated());
}, transformer: sequential());
}
}

The run ends at Broken(cable unplugged) without publishing Calibrated. Calibration checks state after each await because the failure event does not cancel its handler or emitter.

If both events use one sequential registration, the failure notification waits behind calibration. The measured states become [Calibrated, Broken(cable unplugged)], briefly reporting success after the device failed. Thus this approach uses a separate failure handler and explicit checks in operations that depend on the device state.

Cubit can reflect the notification directly from a subclass method, but its asynchronous operations still need equivalent validity checks.

externalSetState is a protected method for reflecting an independent source’s already completed state change. The listener belongs inside the controller subclass:

final class SensorController extends Solo<SensorState> {
final Sensor _hw;
SensorController(this._hw) : super(const Ready()) {
_hw.onError = (error) => externalSetState(Broken(error));
}
Job<void> calibrate() => run<Ready, void>(
key: 'calibrate',
(ctx) async {
await ctx.join(_hw.zero);
await ctx.join(_hw.sample);
ctx.emit(const Calibrated());
},
);
}

Queuing a normal Job to publish Broken would delay the fact behind the calibration that it invalidates. externalSetState updates state immediately and checks running bodies against their rules. Here, run<Ready, void> permits calibration only while state is Ready, so the external failure cancels it with Cancelled(rules: is not Ready).

This exception applies to facts such as an unplugged cable. A notification asking the controller to perform future work should enqueue an ordinary Job. The source being a stream does not itself justify bypassing the queue. Stop the hardware listener before closing either controller; the snippets show registration, not application-specific listener teardown.

On the success path, the job may finish by emitting Calibrated, even though that state is outside Ready. Its own emit is excluded from the rule check; a later state checkpoint would cancel it. This allows a final transition while requiring the working type to cover continued work.

The sensor call already in progress still finishes in both examples. join waits for it before allowing another root Job to start. A device with a cancellation token can additionally be stopped through ctx.onCancel, as in section 4. Final onError and onCancel state handlers, if supplied, are disabled by an incompatible external update, so they do not overwrite Broken during cleanup.

9. Finishing an in-flight write before restarting

Section titled “9. Finishing an in-flight write before restarting”

A firmware upload writes BLE chunks sequentially. A replacement upload must stop the old loop and wait for its current write before sending any new chunk. This example’s BLE API cannot abort an accepted write.

restartable() cancels the old emitter, but its Dart body continues. Check emit.isDone after each write to stop the old loop:

class FirmwareBloc extends Bloc<FirmwareEvent, FirmwareState> {
final Ble _ble;
FirmwareBloc(this._ble) : super(Idle()) {
on<Flash>((e, emit) async {
var written = 0;
for (final chunk in e.chunks) {
await _ble.write(chunk);
if (emit.isDone) return;
emit(Flashing(++written, e.chunks.length));
}
}, transformer: restartable());
}
}

With the check, a mid-upload restart writes [0, 1, 100, 101, …]. Without it, both loops continue and their chunks interleave. The restartable-handler discussion describes why cancellation does not interrupt awaited futures.

The check alone does not prevent overlapping native writes. A replacement handler starts while the old write is still pending, producing [write 0 start, write 0 end, write 1 start, write 100 start, write 1 end, write 100 end, …] in the test. A shared lock can serialize device calls:

class Lock {
Future<void> _tail = Future.value();
Future<T> protect<T>(Future<T> Function() action) {
final turn = Completer<void>();
final before = _tail;
_tail = turn.future;
return before.then((_) => action()).whenComplete(turn.complete);
}
}
class LockedFirmwareBloc extends Bloc<FirmwareEvent, FirmwareState> {
final Ble _ble;
final _wire = Lock();
LockedFirmwareBloc(this._ble) : super(Idle()) {
on<Flash>((e, emit) async {
var written = 0;
for (final chunk in e.chunks) {
await _wire.protect(() async {
if (emit.isDone) return;
await _ble.write(chunk);
});
if (emit.isDone) return;
emit(Flashing(++written, e.chunks.length));
}
}, transformer: restartable());
}
}

The trace now orders each write’s completion before the next start: [write 0 start, write 0 end, write 1 start, write 1 end, write 100 start, write 100 end, …].

Check emit.isDone inside the lock as well as after the write. An event may be replaced while waiting for the lock; checking only before entry would let its obsolete write start later. The lock orders device access, while the emitter check stops obsolete handlers. Every operation using that device must follow the same locking rule.

A separate HardwareFailed event that publishes Broken does not change this emitter’s isDone. In that scenario, additional state checks are still needed to stop flashing and preserve Broken, as in section 8.

The Job occupies the controller’s queue until its body and cleanup finish:

final class FirmwareController extends Solo<FirmwareState> {
final Ble _ble;
FirmwareController(this._ble) : super(const Idle());
Job<void> flash(List<Chunk> chunks) => run<NotBroken, void>(
key: 'flash',
policy: Policy.restart,
(ctx) async {
var written = 0;
for (final chunk in chunks) {
await ctx.join(() => _ble.write(chunk));
ctx.emit(Flashing(++written, chunks.length));
}
},
);
}

Policy.restart requests cancellation and enqueues the replacement. join waits for the current write; after a successful write it detects cancellation and throws before the next iteration. The replacement starts after the old Job completes. The observed chunk sequence and non-overlap match the locked Bloc implementation.

An external Broken state also cancels this Job because it no longer matches NotBroken. The measured failure run stops after [0, 1, 2] with Cancelled(rules: is not NotBroken). The check covers both replacement and state invalidation.

If the upload starts child jobs through ctx.run, the parent also waits for those children and their cleanup. Work intentionally allowed to outlive the Job can use ctx.unattended, whose errors are reported through the Job’s hooks; it does not keep the queue occupied.

10. Releasing a resource returned after cancellation

Section titled “10. Releasing a resource returned after cancellation”

An audio editor opens a native PCM buffer to draw a waveform. The user selects a different clip while decoding is pending. The old waveform should be discarded, but its buffer must still be released. These decoder calls may overlap safely because their buffers are independent. A decoder requiring serialized access needs section 9’s waiting behavior instead.

Checking emit.isDone before using the result prevents a stale waveform update, but this version returns without releasing the stale buffer:

class PreviewBloc extends Bloc<OpenPreview, PreviewState> {
final Decoder _decoder;
PreviewBloc(this._decoder) : super(const NoPreview()) {
on<OpenPreview>((e, emit) async {
final buffer = await _decoder.open(e.clip);
if (emit.isDone) return;
emit(Preview(buffer.waveform()));
await buffer.release();
}, transformer: restartable());
}
}

Move the check inside a try/finally that owns the acquired buffer:

class GuardedPreviewBloc extends Bloc<OpenPreview, PreviewState> {
final Decoder _decoder;
GuardedPreviewBloc(this._decoder) : super(const NoPreview()) {
on<OpenPreview>((e, emit) async {
final buffer = await _decoder.open(e.clip);
try {
if (emit.isDone) return;
emit(Preview(buffer.waveform()));
} finally {
await buffer.release();
}
}, transformer: restartable());
}
}

Both versions finish at Preview(2). The first releases the current buffer once and the stale buffer zero times; the guarded version releases each once. Testing state alone would miss the leak.

The handler continues waiting after emitter cancellation and releases the buffer when it arrives. This is a working resource-management pattern; every exit after acquisition must remain inside the try block. Additional resources or ownership transfers need corresponding cleanup decisions. Cubit can use the same pattern with an application-defined stale-request check, since it has no handler emitter or emit.isDone.

Register disposal on the call that acquires the buffer:

final class PreviewController extends Solo<PreviewState> {
final Decoder _decoder;
PreviewController(this._decoder) : super(const NoPreview());
Job<void> open(Clip clip) => run<PreviewState, void>(
key: 'preview',
policy: Policy.restart,
(ctx) async {
final buffer = await ctx.wait(
() => _decoder.open(clip),
dispose: (buffer) => buffer.release(),
);
ctx.emit(Preview(buffer.waveform()));
},
);
}

ctx.wait can end the cancelled Job before the decoder finishes. A late buffer is still passed to dispose; one returned while the Job is active is released during its cleanup. The replacement Job may therefore start without waiting for the obsolete decode, while each buffer is released.

Use dispose here because the buffer is temporary, including on success. discard is for a resource handed to the caller as the successful result; it would not release this temporary buffer after a successful waveform update.

The guarded Bloc and solo runs both record [open 1, open 2, ready 2, sample 2, release 2, ready 1, release 1] and end at Preview(2).

Late disposal can happen after the controller closes. In the measured solo run, close() finishes before the obsolete buffer arrives; it is released when decoding finally returns it. Use join when both the operation and its resource release must finish before the queue or controller proceeds.