solo
solo manages state and asynchronous work in Dart. A controller holds
the current state and processes jobs one at a time. Each job can declare
which states allow it to start and continue, and callers can await its
result or request cancellation.
Use it for screens, sessions and devices where operations share state
and need an explicit order. The package has no Flutter dependency.
flutter_solo adds a controller
that implements ValueListenable for Flutter widgets.
Install
Section titled “Install”dart pub add soloFor Flutter, install flutter_solo. It re-exports solo:
flutter pub add flutter_solosolo re-exports async_job, which
provides jobs, cancellation and resource cleanup. One import,
package:solo/solo.dart, gives access to both APIs. You do not need to
learn the underlying package before using the examples below.
Quick start
Section titled “Quick start”A controller exposes methods for application operations. Here, load()
loads a profile name and reports progress through four immutable states:
import 'package:solo/solo.dart';
sealed class ProfileState { const ProfileState();}
final class Initial extends ProfileState { const Initial();}
final class Loading extends ProfileState { const Loading();}
final class Loaded extends ProfileState { final String name;
const Loaded(this.name);}
final class Failure extends ProfileState { final Object error;
const Failure(this.error);}The example API returns a name after a short delay. Replace it with your application’s API client:
class ProfileApi { Future<String> fetchName() => Future.delayed( const Duration(milliseconds: 20), () => 'Ada Lovelace', );}
final class ProfileController extends Solo<ProfileState> { final ProfileApi api;
ProfileController(this.api) : super(const Initial());
Job<String> load() => run<ProfileState, String>( key: 'load', policy: Policy.droppable, onError: (state, error, stackTrace) => Failure(error), onCancel: (state, cancelled) => const Initial(), (ctx) async { ctx.emit(const Loading()); final name = await ctx.wait(api.fetchName); ctx.emit(Loaded(name)); return name; }, );}run<ProfileState, String> creates and queues a job. ProfileState is
the state type its body can work with; String is its result type.
The body receives a context, ctx, which provides state updates and
cancellation-aware waiting:
ctx.emitupdates the controller’s state.ctx.waitwaits for the API response, or throwsCancelledif the job accepts cancellation while waiting.onErrorreturns the state to publish if the job fails.onCancelreturns the state to publish if a started job is cancelled.
The state handlers run after the body and its cleanup. In this example,
the state becomes Loaded on success, Failure on error, or Initial
on cancellation. Failure and cancellation remain the job’s outcome even
when a handler updates the state.
Policy.droppable and key: 'load' make repeated calls share the queued
or running load. A second call returns the existing job. Once that job
finishes, another call can start a new load.
The caller uses the returned Job<String> to await this particular load:
Future<void> main() async { final profile = ProfileController(ProfileApi()); final subscription = profile.stream.listen(print); try { final job = profile.load(); profile.load(); // the existing job is returned
print(await job.value); if (profile.state case Loaded(:final name)) { print(name); } } finally { await subscription.cancel(); await profile.close(); }}profile.state is available synchronously. profile.stream broadcasts
changes asynchronously. job.value returns the loaded name, or throws
the job’s error or Cancelled. The finally block releases the listener
and closes the controller even if loading fails.
Cancellation uses the same job object. This separate example requests cancellation immediately, so the job may still be in the queue:
Future<void> cancelLoading() async { final profile = ProfileController(ProfileApi()); final job = profile.load();
await job.cancel(); print(job.outcome); // Cancelled(manual) await profile.close();}cancel() waits for the job to finish, including cleanup if it started.
A job cancelled before its body starts does not call onCancel.
Jobs can start child jobs as part of their work; the starting job is
their parent and waits for them before finishing.
The next queued job starts only after the previous job finishes its body,
children, cleanup and state handler.
The guides below explain results, queue policies and cancellation in more detail. In particular, cancellation of a job does not automatically stop an API request that has already been sent.
The dozen calls
Section titled “The dozen calls”Everything reached for day to day, in one controller. PlayerState,
Api and Device belong to the application; the rest is the package.
enum _Op { play }
final class Player extends Solo<PlayerState> { final Api api; final Device device;
Player(this.api, this.device) : super(const Idle());
/// A job on the controller's queue. Root jobs run one at a time. SoloJob<Track> play(String id) => run<PlayerState, Track>( // Any object is a key. A record keys one request, not an operation. key: (_Op.play, id), // A new play cancels the one running under the same key. policy: Policy.restart, // Rules: checked before the start, and at every checkpoint after. canStart: (state) => state is! Disconnected, keepWhile: (state) => state is! Disconnected, (ctx) async { // The only way to change state. There is no setter outside. ctx.emit(const Loading()); // Cancellation ends this wait at once; the request may go on. final track = await ctx.wait(() => api.fetch(id)); // This one is waited out whatever happens, and the value is // released if the job ends before the body could take it. final handle = await ctx.join( () => device.open(track), dispose: (handle) => handle.close(), ); // Cleanup runs in reverse order, on every outcome. ctx.onDispose(handle.close); // A child job: the parent waits for it before it finishes. ctx.each(device.position, (childCtx, position) async { childCtx.emit(Playing(track, position)); }); return track; }, );
/// Events that share one queued job: only the last volume matters. late final _volume = accumulate<PlayerState, double, void>( (ctx, value) => ctx.join(() => device.setVolume(value)), merge: (previous, incoming) => incoming, timing: AccumulationTiming.debounce(const Duration(milliseconds: 50)), );
SoloJob<void> setVolume(double value) => _volume.add(value);}At the call site a job is a handle: await its outcome, or cancel it.
final player = Player(api, device);
final job = player.play('t-1');switch (await job.done) { case Done(:final value): print('playing $value'); case Failed(:final error): print('failed: $error'); case Cancelled(:final reason): print('cancelled: $reason');}
player.setVolume(0.4);// Run what is already queued, then stop accepting work.await player.close(mode: SoloCloseMode.drain);Guides
Section titled “Guides”Also on the documentation site, with search.
| Page | What it covers |
|---|---|
| Jobs and the queue | Submitting work, outcomes, keys, queue policies |
| State | Emitting, rules, observing, external changes, state after failure |
| Cancellation | wait, join, uncancellable, stopping the operation, closing |
| Resources and cleanup | onDispose, dispose and discard, transfer, ordering |
| Children and streams | Child jobs, streams, following another controller |
| Event accumulation | collect, accumulate, debounce and throttle |
| Errors and observation | SoloObserver, errorHandler, pending, logs |
| Testing | Awaiting outcomes, fake time, timeouts |
| Flutter | SoloListenable, owning a controller, rebuilding a screen |
| Camera example | One controller with rules, cleanup and a device |
| solo and bloc, side by side | Ten scenarios in both packages |
Recipes
Section titled “Recipes”Situations that come up, and what to reach for. Each one is explained on the page named beside it.
| Situation | Reach for | Where |
|---|---|---|
| Only the last of a burst of commands matters | accumulate with a merge that keeps the incoming value |
Commands where only the last one counts |
| Typing into a search box | accumulate with AccumulationTiming.debounce |
Event accumulation |
| A later request must not be dropped as a duplicate of an earlier one | a record key, (Op.load, id) |
Jobs and the queue |
| Queued work is made pointless by what just arrived | queue.removeWhere before submitting, or cancelAll() if it may be running |
Commands where only the last one counts |
| A last batch has to go out before the screen goes away | close(mode: SoloCloseMode.drain) |
Cancellation |
close() does not come back |
SoloBase.pending |
Errors and observation |
| A journal needs to say which operation changed the state | SoloTransition in onChange |
Errors and observation |
| A step must not be interrupted halfway | ctx.join for a call, ctx.uncancellable for a step |
Cancellation |
| A resource opened by a call nobody waited for still has to close | dispose or discard on ctx.wait and ctx.join |
Resources and cleanup |
| A widget rebuilds for state it does not use | select on SoloListenable |
Flutter |
Coming from bloc
Section titled “Coming from bloc”Callers invoke controller methods and receive a job for each operation. Queue policy is selected per call, and all root jobs share one queue. solo and bloc, side by side holds the API correspondences and compares ten application scenarios with implementations in both packages.
The package does not include retry policies, built-in timeouts, worker
pools, dependency injection, persistence or state equality filtering.
If work needs cancellation and cleanup but no state rules or controller
queue, async_job can be used directly.
For a value with no asynchronous lifecycle, a ValueNotifier may suffice.