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.

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. The fetchName of a real client throws an ApiException when the call fails:

final class ApiException implements Exception {
  final String message;

  const ApiException(this.message);

  @override
  String toString() => 'ApiException: $message';
}

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() => enqueue<ProfileState, String>(
    key: 'load',
    policy: Policy.droppable,
    ifFailed: (state, error, stackTrace) => Failure(error),
    isExpectedError: (error) => error is ApiException,
    ifCancelled: (state, cancelled) => const Initial(),
    (ctx) async {
      ctx.emit(const Loading());
      final name = await ctx.abandonable(api.fetchName);
      ctx.emit(Loaded(name));
      return name;
    },
  );
}

enqueue<ProfileState, String> creates and queues a job. ProfileState is the job's working type W: the states its body works with, which may be narrower than the controller's state type S. String is the job's result type. Both can be left to inference: W is then the whole of S, and the result type is what the body returns. The body receives a context, ctx, which provides state updates and cancellation-aware waiting:

  • ctx.emit updates the controller's state.
  • ctx.abandonable waits for the API response, or throws Cancelled if the job accepts cancellation while waiting.

Two optional parameters of enqueue give the state to publish when the job does not succeed:

  • ifFailed returns the state to publish if the job fails.
  • ifCancelled returns 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. Without a handler, the state stays where the body left it: an error or a cancellation here would leave Loading. A handler that hands back the state it was given, the same object, publishes nothing. The job's outcome stays Failed or Cancelled 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());
  void onChange() => print(profile.currentState);
  profile.addListener(onChange);
  try {
    final job = profile.load();
    profile.load(); // the existing job is returned

    print(await job.value);
    if (profile.currentState case Loaded(:final name)) {
      print(name);
    }
  } finally {
    profile.removeListener(onChange);
    await profile.close();
  }
}

profile.currentState is available synchronously, and addListener calls back synchronously too, inside the change itself. job.value returns the loaded name, or throws the job's error or Cancelled. The finally block removes the listener and closes the controller even if loading fails.

A Failed that nobody observes goes on to the zone the job was created in, even when ifFailed has published a state, unless Solo.unansweredHandler answers for it. An error the job names in isExpectedError goes no further. load names ApiException, so a profile.load() that nobody reads and that fails with one ends at the Failure state; a bug is not an ApiException, and it still goes on. Reading the job's done or value observes the outcome, and a caller that needs no outcome at all says so with ignoreFailure(). See Naming the expected errors and Handled and unhandled failures on the errors page.

For a broadcast stream as well, delivered on the next microtask, mix in SoloStream:

final class ProfileController extends Solo<ProfileState> with SoloStream {
  // ...same as above...
}

See Observing state on the state page for the full delivery picture, including SoloListenable from flutter_solo.

Cancellation uses the same job object. This separate example requests cancellation immediately, so the job is still 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 ifCancelled. 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

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.

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 Quick start says, 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.

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;
});

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 puts the pages together in one controller, and solo and bloc side by side is for a reader who comes from bloc.

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

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

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.