solo under riverpod
An app built on riverpod keeps riverpod: the graph of providers, the cache, the
AsyncValue a screen switches on. This page is about what goes under a
provider when the work there has an order to keep and a resource to close. A
solo controller lives in a provider: the provider makes it, the screen reads
its state through it, and the provider’s disposal closes it. The code runs
against riverpod 3 in pure Dart: flutter_riverpod hands out the same Ref.
Where riverpod leaves the work to the app, this page and its neighbours take it apart:
| The trap in riverpod | What answers it | Where |
|---|---|---|
A Ref used after an await in a method of a notifier, a field checked against the current call |
A method of a controller holds no Ref, and the emit of a cancelled job never reaches the state |
A notifier that starts again |
| A provider disposed while its work runs | ref.onDispose(job.cancel) |
A provider that is gone |
| A debounce made of a flag and a throw after the disposal | ctx.pause in a job under the provider; in solo, accumulate with debounce |
A pause of the job, A search that fires on every keystroke |
| The state of a controller on the screen | ref.notifyListeners and select |
A controller under a provider |
| A resource taken before its cleanup is registered | discard where it is taken, onClose for what the controller keeps |
A camera closed when the screen goes |
| A form sent twice | Policy.droppable |
A form sent once |
The debounce of riverpod’s guide
throws Exception('Cancelled') after the disposal. An app that wants to tell
that cancellation from a failure by its type throws a class of its own;
async_job gives every piece of code that type already: Cancelled.
Each section opens with the version riverpod’s own API leads to and shows what that code does. The states and traces under the fragments are what the code prints when it runs.
A controller under a provider
Section titled “A controller under a provider”The controller is a camera. open() publishes Opening, opens the device and
publishes Ready with it:
sealed class CameraState { const CameraState();}
final class Closed extends CameraState { const Closed();}
final class Opening extends CameraState { const Opening();}
final class Ready extends CameraState { const Ready(this.device);
final Device device;}final class Camera extends Solo<CameraState> { Camera() : super(const Closed());
Job<void> open() => enqueue<CameraState, void>( key: #camera, policy: Policy.restart, ifCancelled: (state, cancelled) => const Closed(), (ctx) async { ctx.emit(const Opening()); final device = await ctx.join( Device.open, discard: (device) => device.close(), ); ctx.emit(Ready(device)); }, );}Device stands for the platform’s camera: opening it takes 50 ms, and
close() releases it. ctx.join waits for the opening even when the job is
cancelled meanwhile, and its discard closes a device that arrives for a job
that did not end Done. ifCancelled puts the camera back to Closed when
the job is cancelled — by the next open(), under Policy.restart, or by the
controller’s close(). The traces show the device by its number: Ready(1)
holds the first one opened.
The provider makes the controller and closes it on disposal. The screen needs
the state as well, and the controller calls its listeners on every change.
ref.notifyListeners is what riverpod gives a provider whose object changes in
place, so the controller’s listener is that call.
The first attempt
Section titled “The first attempt”// The first attempt: the controller calls a Ref already disposed.final camera = Provider.autoDispose<Camera>((ref) { final controller = Camera()..addListener(ref.notifyListeners); ref.onDispose(() => unawaited(controller.close())); return controller;});The screen opens the camera and leaves 10 ms later, while the device is still
opening. outcome is that of the job open() returned, states are what the
screen saw, and device is what happened to the device:
screen left at 10 msoutcome: Cancelled(closed)states: Closed, Openingdevice: open 1 start, open 1 done, close 1zone: Cannot use the Ref of Provider<Camera> after it has been disposed.The screen was the provider’s last listener, so riverpod disposes the provider,
and onDispose closes the controller. close() cancels the opening, and its
ifCancelled publishes Closed. The controller’s listener is still
ref.notifyListeners of a Ref disposed a moment ago; the call throws, and
the controller hands a listener’s error to the zone.
Removing the listener
Section titled “Removing the listener”final camera = Provider.autoDispose<Camera>((ref) { final controller = Camera()..addListener(ref.notifyListeners); ref.onDispose(() { controller.removeListener(ref.notifyListeners); unawaited(controller.close()); }); return controller;});
// The screen: ref.watch(camera.select((camera) => camera.currentState))The same screen leaves 10 ms in:
screen left at 10 msoutcome: Cancelled(closed)states: Closed, Openingdevice: open 1 start, open 1 done, close 1zone: nothingThe listener goes before the controller closes, and the Closed of the
cancellation reaches no one: the screen is gone with the provider.
removeListener compares with ==, and two tear-offs of ref.notifyListeners
are equal, so naming the method twice removes what the first line added.
The screen reads the state with select: the provider itself changes nothing
but notifies, and select hands the screen each new currentState. A
StreamProvider over the stream of SoloStream would not do: the stream
carries changes, not the state the camera is in when the screen subscribes. In
Flutter the screen can also take the controller with
ref.watch(camera.select((camera) => camera)) and build from it with
SoloBuilder of flutter_solo. The
select matters: the bridge notifies the listeners of camera on every change
of state, and a widget with ref.watch(camera) would rebuild with each of
them.
A camera closed when the screen goes
Section titled “A camera closed when the screen goes”The screen opens the camera when it appears and closes it when it goes,
whichever moment that is: while the device is still opening or after it opened.
Without the controller, riverpod holds the device in a provider of its own and
closes it in onDispose.
The first attempt
Section titled “The first attempt”// The first attempt: the cleanup is registered after the await.final camera = FutureProvider.autoDispose<Device>((ref) async { final device = await Device.open(); ref.onDispose(device.close); return device;});The screen leaves 10 ms in, while the device opens; an observer of the container prints what fails:
screen left at 10 msdevice: open 1 start, open 1 doneobserver: UnmountedRefExceptionThe device stays open. riverpod disposes the provider when the screen leaves,
and the onDispose that would close the device is not registered yet: the line
that registers it waits behind the await. When the opening ends, the provider
is gone, and that line throws UnmountedRefException. A ProviderObserver
sees it in providerDidFail, as a Ref used after its disposal; nothing names
the open device.
A flag before the await
Section titled “A flag before the await”riverpod’s guide to cancelling has
the provider note its disposal before the first await and check after it:
final camera = FutureProvider.autoDispose<Device>((ref) async { var disposed = false; ref.onDispose(() => disposed = true); final device = await Device.open(); if (disposed) { await device.close(); throw StateError('the screen left while the camera opened'); } ref.onDispose(device.close); return device;});The screen leaves 10 ms in, and then, with a fresh provider, 80 ms in:
screen left at 10 msdevice: open 1 start, open 1 done, close 1screen left at 80 msdevice: open 1 start, open 1 done, close 1This works: the device is closed on either side of the opening. The cleanup
lives in two places, the check after the await and the onDispose that
follows it, and the order is the author’s to keep: the flag is registered
before the await.
Leaving after the camera opened
Section titled “Leaving after the camera opened”Under the controller of the first section, leaving while the device opens
closes it: discard takes the device that arrives for a cancelled job. The
same screen leaving 80 ms in, after the opening:
screen left at 80 msoutcome: Done(null)states: Closed, Opening, Ready(1)device: open 1 start, open 1 doneThe device stays open. close() cancels the controller’s jobs, and by then the
opening has ended Done: there is nothing left to cancel. The device in
Ready is the controller’s now, not a job’s, and close() does not close it.
onClose
Section titled “onClose”final class Camera extends Solo<CameraState> { Camera() : super(const Closed());
Job<void> open() => enqueue<CameraState, void>( key: #camera, policy: Policy.restart, ifCancelled: (state, cancelled) => const Closed(), (ctx) async { ctx.emit(const Opening()); final device = await ctx.join( Device.open, discard: (device) => device.close(), ); ctx.emit(Ready(device)); }, );
@override void onClose() { if (currentState case Ready(:final device)) { unawaited(device.close()); } }}The screen leaves 10 ms in, and then, with a fresh provider, 80 ms in:
screen left at 10 msoutcome: Cancelled(closed)states: Closed, Openingdevice: open 1 start, open 1 done, close 1screen left at 80 msoutcome: Done(null)states: Closed, Opening, Ready(1)device: open 1 start, open 1 done, close 1onClose runs once, when every job of the controller is over, and closes the
device the state holds. Leaving during the opening goes as before: the job ends
Cancelled, ifCancelled publishes Closed, and discard closes the device.
Neither a flag nor the order of the registrations is left to the author:
discard at the place the device is taken holds the race between the leave and
the opening, and onClose holds what the controller keeps after that. A
controller whose closing is a job of its own, one that waits for the hardware,
is in Disposing of the camera.
A form sent once
Section titled “A form sent once”The form sends a message to the server, and a second tap while the message is
on its way must not send it again. send stands for the API: it answers in 30
ms with a receipt numbered by its call.
The first attempt
Section titled “The first attempt”riverpod 3 has mutations for the submission of a form, still experimental, in
package:riverpod/experimental/mutation.dart. Their documentation swaps the
button for a progress indicator while the mutation is MutationPending. A
caller that does not go through that button reads the same state before it runs
the mutation:
// The first attempt: each caller checks the mutation before it runs.final sendMessage = Mutation<String>();
Future<String?> submit(ProviderContainer container, String text) async { if (container.read(sendMessage) is MutationPending) return null; return sendMessage.run(container, (_) => send(text));}In Flutter the same lines take the WidgetRef of the screen. The screen sends
'hi', and then 'bye' while 'hi' is on its way:
right after the first: MutationPending<String>()first: sent hi #1second: nullcalls: 1The guard works: run puts the mutation into MutationPending before it
returns, and the second call finds it there. What sets it apart from the answer
below is narrow. The second caller gets null, not the receipt of the message
that went. And the guard lives in each caller, not in the mutation. Its
documentation says: “Currently, mutations do not restrict concurrent calls in
any capacity.” A caller without the check sends the message a second time.
Policy.droppable
Section titled “Policy.droppable”The form is a controller, under a provider the way the camera is in the first section:
sealed class MessageState { const MessageState();}
final class Editing extends MessageState { const Editing();}
final class Sent extends MessageState { const Sent(this.receipt);
final String receipt;}final class MessageForm extends Solo<MessageState> { MessageForm() : super(const Editing());
Job<String> submit(String text) => enqueue<MessageState, String>( key: #submit, policy: Policy.droppable, (ctx) async { final receipt = await ctx.join(() => send(text)); ctx.emit(Sent(receipt)); return receipt; }, );}The same 'hi' and 'bye', each awaiting the value of the job its submit
returned:
same job: truefirst: sent hi #1second: sent hi #1calls: 1state: Sent(sent hi #1)A call that comes while a job of its key runs gets that job back under
Policy.droppable, and the API is called once. Both callers await the value
of one job and get the receipt. A caller that goes on with then takes the job
through shared() instead, as Queue and policies
explains. The send goes through ctx.join: a message is work that has to
finish before the queue moves on. Under ctx.abandonable a cancelled send
would go on after its job ended, and the next submit would start a second one
while the first is still on its way.
The price of droppable is the other side of the same rule: a second call with
other arguments gets the first job as well. The 'bye' typed while 'hi' was
on its way is never sent, and its caller gets the receipt of 'hi'.
What you take on
Section titled “What you take on”The controller adds a layer. The provider holds the controller, the controller
holds the state, and the state is a type of the controller, not an
AsyncValue. The bridge of the first section is code of the app, one per
controller, and the removeListener in its onDispose is not optional.
The provider decides how long the controller lives. A ref.read of an
autoDispose provider with no listener makes a controller and disposes of it
right away, and the job it was asked for ends Cancelled:
read without a listeneroutcome: Cancelled(closed)device: open 1 start, open 1 done, close 1A rebuild of the provider closes the controller and cancels its jobs:
ref.invalidate, or a change in a provider the bridge reads with ref.watch.
Here the screen stays while the camera is open, and the provider is
invalidated; the screen gets a new controller, in Closed:
camera invalidatedstates: Closed, Opening, Ready(1), Closeddevice: open 1 start, open 1 done, close 1the first controller finished: trueriverpod does not retry a failed job of the controller: its retry is for a provider that fails to build, and the provider of a controller does not fail. A retry is written in the body of the job, as Retrying a failed call shows.
Policy.droppable loses the arguments of a second call, as the form shows. And
solo is one more dependency with a model of its own: a queue, keys, policies,
state rules.
solo is not needed for one operation under a provider: that is a job of
async_job, as
With riverpod and bloc
shows. Nor for a request riverpod keeps per argument with a provider family:
riverpod alone does that.