flutter_solo
State management for Flutter: sequential jobs over one state, exclusive
ownership, cooperative cancellation, and rebuilds through
ValueListenable.
SoloListenable<S> is a controller that owns a state and runs jobs over
it one at a time, and it is a ValueListenable<S> at the same time — so
it drops straight into ValueListenableBuilder, ListenableBuilder,
AnimatedBuilder and Listenable.merge.
The documentation site carries this page and the guides of the packages below it.
A screen has a lifecycle, and so does the work on it: a load that must be
dropped when the user leaves, a save that must not be cut in half, a
second tap that must not start a second request. setState and
ChangeNotifier give you somewhere to keep the state and say nothing
about the work; bloc gives you the work and asks for an event class per
call.
Here a method stays a method, and it hands back a handle:
final job = profile.load();
await job.cancel(); // returns when the job has actually stoppedprint(job.outcome); // Cancelled(manual)What the engine holds to:
- one root job of a controller at a time, in queue order, so two of them never write the same state;
- rules instead of flags — a job declares the states it works with and the condition it lives under, and it is cancelled when they stop holding;
- a cancellation the caller can ask for and the body cannot walk past by forgetting a check;
- an outcome for every job —
Done,FailedorCancelled— which is what a screen has to show anyway.
The long argument, ten scenarios solved in bloc first and then here, is in solo and bloc, side by side.
What is not here: no SoloProvider and no code generation, no dependency
injection, no persistence, and no parallel root jobs — one at a time is
the subject of the package, not a limit of its engine.
Install
Section titled “Install”flutter pub add flutter_soloOne dependency is all it takes: flutter_solo re-exports the whole of
solo, which re-exports the whole of
async_job. Solo, SoloContext, Job,
Outcome, Policy and ValueListenable all arrive with
import 'package:flutter_solo/flutter_solo.dart';and SoloListenable is the single class this package adds on top.
import 'package:flutter/material.dart';import 'package:flutter_solo/flutter_solo.dart';
sealed class Profile {}
final class Empty extends Profile {}
final class Loading extends Profile {}
final class Loaded extends Profile { final String name;
Loaded(this.name);}
final class ProfileController extends SoloListenable<Profile> { final ProfileApi api;
ProfileController(this.api) : super(Empty());
Job<String> load() => run<Profile, String>( key: 'load', policy: Policy.droppable, // a second tap returns the first job (ctx) async { ctx.emit(Loading()); final name = await ctx.wait(api.fetchName); ctx.emit(Loaded(name));
return name; }, );}
class ProfileView extends StatelessWidget { final ProfileController controller;
const ProfileView({required this.controller, super.key});
@override Widget build(BuildContext context) => ValueListenableBuilder<Profile>( valueListenable: controller, builder: (context, state, _) => switch (state) { Empty() => TextButton( onPressed: controller.load, child: const Text('Load'), ), Loading() => const CircularProgressIndicator(), Loaded(:final name) => Text(name), }, );}run<Profile, String> says the job works with Profile states and
returns a String; inside the body ctx.emit is the only way to write
the state, and ctx.wait awaits like await except that it gives up the
moment the job is cancelled. The full API — rules, the queue, children,
observers — is documented in solo.
Selecting one value
Section titled “Selecting one value”A widget that needs one field does not have to rebuild for the rest.
select hands back a ValueListenable of that field alone, and it
notifies only when the field itself changes:
class _SaveButtonState extends State<SaveButton> { late final canSave = widget.controller.select((state) => state.canSave);
@override Widget build(BuildContext context) => ValueListenableBuilder<bool>( valueListenable: canSave, builder: (context, canSave, _) => ElevatedButton( onPressed: canSave ? widget.controller.save : null, child: const Text('Save'), ), );}Hold the selection in a field, the way canSave is held above: one built
inside build would subscribe and unsubscribe every frame, and so would
a selector written inline there. Picks count as changed when they are
!=, unless compare: answers that question itself — true means
changed. The source is subscribed to only while the selection has
listeners, and there is nothing to dispose of. value reads the state
every time, so it is never behind — keep the selector a cheap pick.
select is an extension, so a controller of your own with a select
method keeps it: yours wins, and the selection is then built directly,
SoloSelection(controller, (state) => state.canSave).
Listening without keeping the callback
Section titled “Listening without keeping the callback”addListener has to be given the same callback back, so a closure needs
a field of its own to live in. listen keeps it instead and hands back a
SoloSubscription; SoloSubscriptions cancels a group of them at once:
final _listening = SoloSubscriptions();
@overridevoid initState() { super.initState(); widget.controller.listen(_onState).addTo(_listening); canSave.listen(_onCanSave).addTo(_listening);}
@overridevoid dispose() { _listening.cancel(); super.dispose();}listen works on a controller and on a selection alike. Cancelling
twice does nothing the second time, and a group that has been cancelled
cancels what it is handed rather than keeping it. If one member refuses
to let go, the others are cancelled all the same: the first error is
thrown once the pass is over and the rest are reported.
The controller’s life
Section titled “The controller’s life”Nothing closes a controller for you. There is no SoloProvider: a
controller is an object, and it lives wherever the rest of your objects
live.
class _ProfileScreenState extends State<ProfileScreen> { final controller = ProfileController(ProfileApi());
@override void initState() { super.initState(); controller.load().ignore(); }
@override void dispose() { unawaited(controller.close()); super.dispose(); }
@override Widget build(BuildContext context) => ProfileView(controller: controller);}close() cancels whatever is running — the body stops at its next context
call, which is what makes leaving the screen cheap — drops every listener
and stops notifying for good. It is safe on either side of
super.dispose(). SoloListenable is not a ChangeNotifier: the method
is close(), not dispose(), and it returns a Future that completes
when the job has actually stopped.
A controller shared by several screens lives where your other singletons
live — a get_it registration, an InheritedWidget, a field of the app
object — and is closed there, once.
Outcomes
Section titled “Outcomes”A job hands back a handle, so the screen can wait for the end of the work it started:
Future<void> _load() async { switch (await controller.load().done) { case Done(:final value): if (mounted) _toast('hello $value'); case Failed(:final error): if (mounted) _toast('$error'); case Cancelled(): break; // left the screen, or a second tap while the first ran }}done never throws; value gives the value and rethrows the failure.
mounted after an await is the usual Flutter rule and it applies here
too. A job nobody looks at is not silent: an unobserved Failed reaches
the zone that created the job, so a fire-and-forget call is
controller.load().ignore() — the ignore() is what says the outcome is
nobody’s business. The same road carries a failure of work handed to
ctx.unattended when neither onError nor a SoloObserver took it.
What “to the zone” means in a Flutter app: the error travels the zones
outwards, so an error zone of your own around runApp sees it first; past
that it reaches PlatformDispatcher.instance.onError if you set one, and
the engine’s log if you did not. What happens next is that callback’s
business and the embedder’s — the framework does not promise to carry on,
and PlatformDispatcher.onError may end the process. What is not the
answer here is the EXIT=255 of a plain Dart program: that one is the
VM’s own reaction to an unhandled error, and it is not Flutter’s.
Testing
Section titled “Testing”testWidgets runs on a fake clock: a job waiting on a timer stays there
until the test moves time itself, and the frame is always one behind the
state. pumpAndSettle does both — it runs the clock out and rebuilds:
testWidgets('the profile appears', (tester) async { final controller = ProfileController(FakeApi()); addTearDown(controller.close); await tester.pumpWidget( MaterialApp(home: ProfileView(controller: controller)), );
await tester.tap(find.text('Load')); await tester.pumpAndSettle();
expect(find.text('Ada Lovelace'), findsOneWidget);});The handle is the precise version of the same thing, for a test that has to know the job ended rather than that the screen went quiet:
final job = controller.load();await tester.pump(const Duration(milliseconds: 20)); // let the clock runawait job.done;await tester.pump(); // the frame that shows the last stateawait job.done on its own is a deadlock if the work waits on a timer:
nothing inside testWidgets advances the clock but the test. What also
does not work is starting a job and pumping once — the state moves on a
microtask after that pump, and whether the expectation sees it depends on
how much was queued.
One Flutter-specific trap: an unobserved Failed fails the test itself,
and tester.takeException() does not catch it — the error goes to the
zone of the test, not through FlutterError.onError. Either await the
outcome or call ignore().
| Question | Answer |
|---|---|
| When do listeners run? | Synchronously, in subscription order, on every state change. Solo’s stream still works and arrives a microtask later; value and state are the same object. |
| Are equal states filtered? | No. emit of a state equal to the current one still notifies, the way Solo does. A frame may swallow several of them, a listener will not. select filters its own value, which is the one a widget usually cares about. |
| Several controllers on one screen? | Each with its own builder, as expected. Listenable.merge([a, b]) in a ListenableBuilder covers the case where one widget depends on two. |
Can I set value? |
There is no setter. The state belongs to the jobs; a ValueNotifier face with a setter would give it away. |
solo is the controller itself: the
queue and its policies, the working type of a job, canStart and
keepWhile, children, observers, the waiting family and the rest of the
API this package inherits whole. If you are not writing widgets, take it
instead — it is pure Dart.