solo

solo manages state and asynchronous work in Dart. A controller holds the current state and processes jobs one at a time, hence the name. 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 the Flutter side: SoloListenable, a mixin that makes a controller a ValueListenable, the widgets SoloBuilder and SoloSelector, and listen, which hands back a subscription to cancel instead of asking you to keep the callback.

Install

dart pub add solo

For Flutter, install flutter_solo. It re-exports solo:

flutter pub add flutter_solo

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

The analyzer plugin solo_lints reports most of the common mistakes in the code. Name it in analysis_options.yaml:

plugins:
  solo_lints: ^0.1.3

That turns on its warnings; the lints are turned on by name, as its Install section shows.

Quick start

A screen loads the name of a profile, and the user pulls to refresh and cancels the refresh before the answer comes. A controller holds the state of the screen and runs its jobs one at a time. ProfileApi is the application's client, and its fetchName() returns the name:

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 ProfileController extends Solo<ProfileState> {
  final ProfileApi api;

  ProfileController(this.api) : super(const Initial());

  Job<String> load() => enqueue((ctx) async {
    ctx.emit(const Loading());
    final name = await ctx.abandonable(api.fetchName);
    ctx.emit(Loaded(name));
    return name;
  });
}

Future<void> main() async {
  final profile = ProfileController(ProfileApi());
  profile.addListener(
    () => print(switch (profile.currentState) {
      Initial() => 'initial',
      Loading() => 'loading',
      Loaded(:final name) => 'loaded $name',
    }),
  );

  print(await profile.load().value);

  // The user pulls to refresh, then cancels the refresh before the answer.
  final refresh = profile.load();
  await Future<void>.delayed(const Duration(milliseconds: 10));
  await refresh.cancel();
  print(refresh.outcome);

  await profile.close();
}

example/bin/quick_start.dart runs this block with an API that answers after 20 ms. It prints:

loading
loaded Ada Lovelace
Ada Lovelace
loading
Cancelled(manual)

enqueue queues a job and hands it back. In the body, ctx.emit publishes a state, which the listener hears inside the change, and ctx.abandonable waits for the API and ends the wait at once when the job is cancelled. The caller awaits value or calls cancel(), which returns once the job is over. The wait before cancel() lets the refresh start: a job cancelled before its body runs leaves the state alone. close() drops the listener; in Flutter, flutter_solo has the widgets and a listen that hands back a subscription.

The cancelled refresh left the controller at loading, and nothing takes it out of there. State after failure or cancellation starts from this load and gives it the state to publish when a job fails or is cancelled. The profile controller is this controller as the guides use it, with the parameters an application adds.

The dozen calls

Everything reached for day to day, in one controller. PlayerState with its states, Track, Download, 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 fact from outside the queue: published at once, and the rule of
    // the running job is asked about it.
    device.onDisconnect = () => externalSetState(const Disconnected());
  }

  /// A job on the controller's queue. Root jobs run one at a time, and
  /// this one ends when the track starts: the device plays it on its own.
  Job<Track> play(String id) => enqueue<PlayerState, Track>(
    // Any object is a key. This one keys the operation, not a track.
    key: _Op.play,
    // A new play cancels the one running under the same key.
    policy: Policy.restart,
    // A rule: asked before the start, and on every change of state
    // while the body runs, except one the body makes with `ctx.emit`.
    keepWhile: (state) => state is! Disconnected,
    // The state to publish when the job fails or is cancelled. If the
    // job is cancelled by the rule, nothing is published and
    // `Disconnected` stays. A play replaced by a newer one hands back
    // the state it was given, which publishes nothing.
    ifFailed: (state, error, stackTrace) => const Idle(),
    ifCancelled: (state, cancelled) =>
        cancelled.reason is ReplacedCancelReason ? state : const Idle(),
    (ctx) async {
      // The body's way to change state. There is no setter outside.
      ctx.emit(const Loading());
      // Waited out whatever happens: the track playing now stops
      // before another one loads.
      await ctx.join(device.stop);
      // Cancellation ends this wait at once; the request may go on.
      final track = await ctx.abandonable(() => api.fetch(id));
      // Waited out as well. `dispose` closes the download when the
      // job ends, or as soon as the call returns if the job was
      // cancelled meanwhile.
      final download = await ctx.join(
        () => api.download(track),
        dispose: (download) => download.close(),
      );
      // A child job: the device reads the download in, and its stream
      // ends when the track starts. The body awaits its `value` and
      // goes on once the track plays.
      await ctx.each(device.load(download), (childCtx, percent) {
        childCtx.emit(Buffering(track, percent));
      }).value;
      ctx.emit(Playing(track));
      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)),
  );

  Job<void> setVolume(double value) => _volume.add(value);

  // The callback is removed before the state is final: after that, a call
  // to `externalSetState` would throw a `StateError`.
  @override
  void onClose() => device.onDisconnect = null;
}

At the call site a job is a handle: await its outcome, or cancel it as the quick start does. A job is not a Future, so await player.play('t-1') compiles and waits for nothing; the core set of package:lints flags it with await_only_futures. Await done or value.

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);
// Stop taking new work now, run what is already queued, then close.
await player.close(mode: SoloCloseMode.drain);

setVolume hands back a job too, and the call site drops it on purpose: nothing here can answer for a volume the device refuses. Its failure goes on to the zone, as Handled and unhandled failures on the errors page has it, since no unansweredHandler is set, and ends this plain Dart program. A caller that can answer for it reads the job's done, as the call site does for play.

A job that the rules of its working type turn down at its start ends Cancelled with a RulesCancelReason, not Failed. That is no error: neither onError nor the zone hears of it, and the outcome is what says so. Solo.debug traces the drop of a queued job too.

Why currentState and not state

Say the name in Quick start is fetched for the user of the session. The session is another controller, and ProfileController keeps it in the field session. The profile API now takes the user: fetchName(user). A method that reloads a loaded profile:

Job<String> reload() => enqueue<Loaded, String>((ctx) async {
  // Another controller's snapshot: a plain read, and the name says
  // as much.
  final user = session.currentState.user;
  final name = await ctx.abandonable(() => api.fetchName(user));
  // This job's own state: a checkpoint that throws `Cancelled` if
  // the job has been cancelled or the state is no longer `Loaded`.
  if (ctx.state.name != name) {
    ctx.emit(Loaded(name));
  }
  return name;
});

enqueue<Loaded, String> names two types: the job's working type W, the states its body works with, which may be narrower than the controller's state type S, and its result type. The working type holds from the start of the job to its end: a body that emits a state outside W is cancelled at its next checkpoint, as Reading and updating state shows. Left out, as in the quick start, the result type is what the body returns, and the job runs with the whole of S as its working type. The compiler does not see that W, though: in the body ctx.state is then Object?, not ProfileState. A body that reads ctx.state names both types.

A job body is a closure inside a method of the controller, so every member of the controller is in scope there. ctx.state is a checkpoint. Before it reads, it checks that the job has not been cancelled and that its rules still hold: the working type, here Loaded, and the keepWhile rule of a job that has one, like play above. A plain read named state would look exactly like ctx.state, and a body that typed it out of habit would read past a cancellation it was supposed to honour: no Cancelled, no check of the rules, and the whole of S instead of W, with no name to read. So the controller's own read is named currentState: a bare state in a body does not compile, and nobody types currentState by habit where a checkpoint is meant.

Outside a job currentState is the read to use. Inside one it stays right for a different controller: session.currentState above is somebody else's snapshot, and this job's rules have nothing to say about it.

Guides

Also on the documentation site, with search.

The first three pages are for every controller, in this order: jobs, state, cancellation. The next five come when a controller needs what they cover: resources, children, streams, accumulation and its rules. Diagnostics, errors and testing are for a controller about to ship, the common mistakes are a list to check against, and the reference names every member with the section that explains it. Flutter is for a controller behind a screen, the camera example joins them in one controller, solo and bloc side by side is for one who comes from bloc, solo under riverpod and solo under bloc for one who stays on either.

Page What it covers
Jobs and the queue Submitting work, outcomes, keys, queue policies
State Emitting, rules, observing, external changes, state after failure
Cancellation abandonable, join, pause, uncancellable, timeout, stopping the operation, closing
Resources and cleanup onDispose, dispose and discard, transfer, ordering
Children and chains Child jobs, chains, steps in a row
Streams ctx.each in a controller, following another controller
Event accumulation collect, accumulate, debounce and throttle in four recipes
Accumulation rules When a group is ready, AccumulationPolicy, one job for a group and shared(), merge, close()
Diagnostics SoloObserver, pending, logs, a slow cancellation
Errors onError, unansweredHandler, catching and retrying, ctx.unattended
Testing Awaiting outcomes, fake time, timeouts
Common mistakes Seven mistakes that often pass a first test, and the right way for each
Reference Every member of Solo, its context, jobs, queue and accumulators, with the section that explains it
Flutter The flutter_solo package: SoloListenable, owning a controller, rebuilding a screen
Camera example One controller with rules, cleanup and a device
solo and bloc, side by side Eleven scenarios in both packages
solo under riverpod A controller under a provider: the bridge, a camera closed when the screen goes, a form sent once
solo under bloc A controller under a bloc: the bridges and what they cost, three requirements where the controller takes a workaround off the bloc

Recipes

Situations that come up, and what to reach for. Each one is explained in the section linked 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 A search that fires on every keystroke
A later request must not be dropped as a duplicate of an earlier one a record key, (_Op.load, id) A key for each request
Queued work is made pointless by what just arrived queue.removeWhere before submitting, or cancelAll() if it may be running When they are separate jobs after all
A last batch has to go out before the screen goes away close(mode: SoloCloseMode.drain) Cancelling and closing a controller
close() does not come back controller.pending What is holding the controller
A journal needs to say which operation changed the state SoloTransition in onChange Watching every controller
A cancelled job must wait for the call it started ctx.join Each wait in its place
A call fails now and then and should be tried again a loop in the body with ctx.pause between attempts Retrying a failed call
A step of several calls must not be interrupted halfway ctx.uncancellable Protecting a step or a whole job
A resource opened by a call nobody waited for still has to close dispose or discard on ctx.abandonable and ctx.join Taking a resource from a call
A widget rebuilds for state it does not use SoloSelector from flutter_solo Selecting one value
The queue has to stand still for a while a job waiting on a Completer at the head of it Pausing the queue
A job has to start later, and the queue must not wait for it a Job of its own for the wait, cancelled from cancelAll and onClose A delayed start
A tap must not wait for a long reload of the same screen a controller of its own for the taps, and marks the reload settles A tap that does not wait for a reload
The screen has to show news until the user closes it, and the camera must go on working a controller of its own for the notices, and an acceptance that names the notice A notice the screen accepts
A stream event arrives a microtask late, and that is too late addListener, which calls back inside the change Observing state

Versions

A change Comes in
A fix that keeps the public API a patch release, such as 1.0.1
A new class, member or optional parameter a minor release, such as 1.1.0
A removed or renamed member, a changed signature or documented behaviour a major release, such as 2.0.0
A new value of an enum or a new subclass of a sealed class a major release, such as 2.0.0

From 1.0.0 on, solo follows semantic versioning: the constraint ^1.0.0 takes every minor and patch release, and they keep code written against 1.0.0 working, but for the cases below. The public API is what package:solo/solo.dart and package:solo/listeners.dart export; the text of error messages and of debug output is not part of it. listeners.dart is the listener list a controller notifies through, for a package that builds a delivery of its own on top of solo, as flutter_solo does; an application listens through Solo.addListener and does not need it. A member that goes away is first marked @Deprecated in a minor release, with the name to use instead, and is removed in the next major one.

A minor release can add a member to a class you extend, such as Solo or SoloObserver, and a member of the same name in your own code then stops compiling. It can add a member to an interface you implement for a mock, such as JobContext or SoloContext, and a fake that implements it by hand, rather than through noSuchMethod, then stops compiling too. The changelog of such a release names the members it adds. A new top-level name can clash with the same name imported from another package, until one of the imports hides it with hide.

A switch over a Policy, a SoloPhase, a SoloCloseMode, an AccumulationPolicy or a SoloPending can be exhaustive, and a new value of one of these enums or a new subclass of the sealed SoloPending would make it stop compiling: these come only in a major release, as the same changes to Outcome and JobErrorAnswer of async_job do. CancelReason is open, and a minor release can add a reason, so a switch over reasons keeps a default case.

solo re-exports async_job, so a major release of async_job comes with a major release of solo. solo is also an engine on the protocol of package:async_job/engine.dart, which can change in a minor release: each release of solo holds async_job to one minor version, and a minor release of async_job comes with a minor release of solo. A new minor version of async_job reaches you with that release of solo: until it is out, an app that asks for the new async_job itself does not resolve.

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 eleven application scenarios with implementations in both packages.

The package does not include retry policies, 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.

Libraries

listeners
The listener list a controller notifies through, for a package that builds a delivery of its own on top of solo.
solo
One job at a time: sequential jobs with exclusive state ownership, declarative rules and cooperative cancellation.