Actions
action.dart: typed writes
data.dart is the read side of a route; action.dart is the write side: a submit, a save, a delete, a "mark as paid". It sits beside a page.dart, takes the same segments and query parameters, plus the value being written, input:
// lib/app/orders/$id/refund/action.dart
Future<Refund> action(Ref ref, {required int id, required RefundInput input}) =>
ref.read(apiProvider).refund(id, input);fespalier turns it into a provider with pending and error state, and into helpers on the typed route. After a success it invalidates the data the write made stale, so the page shows what the server says now: no isSubmitting field, no try/catch, no ref.invalidate to forget.
// in a HookConsumerWidget (or any ConsumerWidget): the state of the write, and a way to run it
final refund = RefundRoute.useAction(ref, id: id);
FilledButton(
onPressed: refund.isPending ? null : () => refund.call(RefundInput(amount: 10)),
child: Text(refund.isPending ? 'Refunding...' : 'Refund'),
),
if (refund.hasError) Text('${refund.state.error}'), // the page stays; error.dart is not used
if (refund.state.value case final done?) Text('Refunded ${done.amount}'),
// in a callback or a test: run it once, get the result, or the exception it threw
final Refund done = await RefundRoute.submit(ref, id: 1, input: input);Since 0.5.0. state plus invalidates covers most pages; validate() and optimistic updates (since 0.8.1) are in the two subsections below, and forms (the fespalier_forms package, since 0.11.0) are on their own page.
Parameters. Segments and query parameters bind exactly as in
data.dart: same names, types and errors. The one other parameter isinput: named,required, of any type (RefundInput,String, a record, aList,Object?). Its type is read from the source like a typedextra's, so the generatedsubmitis typed. ARef refcomes first, positional.Return type.
Future<T>,FutureOr<T>or a plainT(Future<void>is fine), spelled out. The helpers keep what the function is: a sync action'ssubmitreturns its value at once (noFuture, no extra frame), aFutureOr<T>one's returns what the function returned, and aFuture<T>one's returns aFuture<T>. Since 0.25.0 aStream<T>is a streaming action, whose state follows the items as they come (until 0.24.0 it was an error).Several actions per file. Every public top-level function that takes a
Reffirst is an action, and its name is the name of its helpers. A function calledactiongets the plain ones:Function Provider ( XRoute.…)Runs it once Hook for buildactionaction(keys)submit(ref, keys…, input:)useAction(ref, keys…)approveapproveAction(keys)approve(ref, keys…, input:)useApprove(ref, keys…)A helper can't be named like a member of the route (
go,refresh,watch,data, …), a segment or query parameter of it, or another action's helper (a function calledsubmitnext toaction): the generator says which.The provider is a generated
Notifierfamily,XRoute.action(id)(XRoute.actionwith no keys). Its state isAsyncValue<T?>:AsyncData(null)while idle, thenAsyncLoading, thenAsyncErrororAsyncDataof the result (a streaming action has a state of its own). It works without a widget (container.read(RefundRoute.action(1).notifier).call(input), which is what a test can do). Each key has its own state; anautoDisposeprovider is dropped when nothing watches it, except while a write is in flight.useActiontakes the keys and returns a handle:state(the sameAsyncValue<T?>),isPending,hasError,fieldErrors(theFieldErrorsthe last run failed with, since 0.8.1, or null),reset()andcall(input).callruns the action and completes with the result, or withnullwhen it failed (the error is instate), soonPressed: () => refund.call(input)can't leave an unhandled error behind.submitis the other way: it throws what the action threw, for code that wants to handle it (the error is instatetoo).- Neither navigates, and neither is for
build's own body: call them from an event handler.useActionis a hook by name only: it needs aWidgetRef, not hooks, and works in anyConsumerWidget.
submit,useActionand the provider are static, likewatchandread(why). Types are inferred from the provider, neverdynamic; the input is the one type spelled out, and it is read from your file.
After a success. The data the write made stale is invalidated and loads again (with keep_previous, what the page shows stays until the new value is there):
- By default: the route's own
data.dartand the section data above it, the setAppRoutes.dataAtlists for the route, for the keys the action was called with. - Or what
const invalidates = [...]lists, which replaces that set. Name typed routes and section handles (RefundRoute,OrderRoute,TeamsTeamIdSection): providers aren'tconst, and the generator knows which provider each one is.const invalidates = <Object>[];invalidates nothing. Anything else the write touches (a provider of your own) the action can invalidate itself, since it has aref.
// lib/app/orders/$id/refund/action.dart
import 'package:my_app/app.g.dart';
/// The quote on this page, and the order page above it, are stale after a refund.
const invalidates = [RefundRoute, OrderRoute];
Future<Refund> action(Ref ref, {required int id, required RefundInput input}) => …;A listed route's data.dart is keyed by something, and the action has to take that key, with that type, to say which one to invalidate (OrderRoute's data.dart takes int id, so the action takes id; a String? q of a search page's data is one the action takes too). When it can't tell, that is an error that says so.
Errors. A failed write is
AsyncErrorin the state, and it is not the page's: the nearesterror.dartis not used, because a failed refund shouldn't replace the form that started it.A write is never retried. The generated provider doesn't use Riverpod's retry, whatever
ProviderScope(retry:)says, and nothing else runs it again. A failed write invalidates nothing. Trying again is the user's call: the nextcallorsubmitreplaces the error, andreset()clears it.Concurrent runs. Nothing stops a second
callwhile one is pending: both run, each invalidates after its own success, and the state follows the last one started (a late result of the first doesn't replace it). Disable the button whileisPendingwhen a double write is wrong.A page that goes away. The provider is kept alive until the write completes, even if its page is popped meanwhile, so the write still finishes and still refreshes the data. The state is only written while the provider is alive, so a submission that finishes after the page, or the whole container, is gone doesn't throw. After an
awaitin a callback, checkcontext.mountedbefore navigating.Navigation is the caller's. An action returns what it wrote and doesn't navigate:
dartfinal done = await RefundRoute.submit(ref, id: id, input: input); if (context.mounted) ReceiptRoute(id: id).go(context);
In a section's folder. An action.dart in a folder with a layout.dart and no page.dart writes to the section. Its helpers are on the section's handle (TeamsTeamIdSection.addMember(ref, teamId: 'acme', input: 'carol')), and what it invalidates by default is the section's own data and the sections above it. A section with no data.dart gets a handle for its actions alone (ShopSection), named like any section's.
Scaffold and diagnostics. fsp new 'orders/[id]/refund' --action scaffolds one, with the path's segments and an Object? input to replace with your own type. fsp routes tags the route action. The generator reports, with a code frame:
- an
action.dartwith nopage.dart, and nolayout.dartof a page-less folder, beside it, or with no function in it that takes aReffirst; - a missing
input, or one that is positional, notrequiredor untyped; - a parameter that is neither a segment, a query parameter nor
input, a missing return type, aFuture,FutureOror (since 0.25.0)Streamwith no type argument; - an
invalidatesthat is not aconstlist literal of names, a name that is neither a typed route nor a section handle or has nodata.dart, and a key of the data it invalidates that the action doesn't take; - helper names that collide;
- since 0.8.1, a
form(),validate()oroptimistic()that doesn't fit its action (see below and Forms); - since 0.25.0, a
form(),stepsoroptimistic()beside a streaming action, which are not built for one; - since 0.25.0, a nullable
Stream<T>?(also through a typedef):draft() returns `Stream<String>?`, and a streaming action never returns null: return a `Stream<T>`, an empty one (`const Stream.empty()`) for no items.
validate() and FieldErrors
Since 0.8.1. validate(), optimistic() and, with the fespalier_forms package, form() are companion functions in the action.dart of the action they belong to, found by name. For the action called action the companion is the role itself; for any other action, say approve, it is approveValidate, approveOptimistic and approveForm. A companion is never read as an action, even when it takes a Ref (that is an error). validate() and optimistic() need nothing but fespalier; form() needs the package, and is on its own page.
// lib/app/(account)/nickname/action.dart
typedef NicknameFields = ({String nickname, int? age, bool newsletter});
/// Checked on the device before the action runs, and live in a form after a first submit.
FieldErrors? validate(NicknameFields input) => FieldErrors({
if (input.nickname.trim().isEmpty) 'nickname': 'Enter a nickname',
if (input.age case final age? when age < 13) 'age': 'You must be 13 or older',
});
/// The server has the last word: it can throw `FieldErrors({'nickname': 'That nickname is taken'})`.
Future<Profile> action(Ref ref, {required NicknameFields input}) => …;validate()isFieldErrors? validate(Input input). It takes noRef(a check the device can make) and does not need a record input. It runs inside the action's provider, before the action, sosubmit,useAction'scalland a test through the provider are refused the same way: the write never starts, there is no loading state, and theFieldErrorsis instateandfieldErrors. It returnsnull(or an emptyFieldErrors) for a valid input.FieldErrorsis what an action, orvalidate(), throws or returns to say which fields of its input are wrong:FieldErrors({'nickname': 'That nickname is taken'}, message: 'Could not save'). The keys are the names of the input's fields;messageis what is wrong with the input as a whole.useAction'sfieldErrorsis the one the last run failed with, ornull.- A check that needs the server belongs in the action, which throws a
FieldErrors. The HTTP package maps a server's validation answer to one. - A form (
form(),useForm, typed fields, a pending state) shows each message under its field. It moved to thefespalier_formspackage in 0.11.0: see Forms.
Telemetry (since 0.18.0): an action that validate() refused, or that threw FieldErrors, ends its span with fespalier.action.invalid set to true, and its fespalier.action.result stays error. A form that stops a submit before the action runs makes no span. Telemetry conventions has the names.
Optimistic updates: optimistic()
Since 0.8.1. T optimistic(T current, Input input) is what the page shows of one data.dart from the moment a write starts until the server's answer is in:
// lib/app/teams/$teamId/action.dart
Team addMemberOptimistic(Team team, String input) =>
Team(team.name, [...team.members, input]);
Future<void> addMember(Ref ref, {required String teamId, required String input}) async => …;- The target is the
data.dartthe action invalidates whose type isT. When several match, the folder's own data comes first, then the sections above it (innermost first), then the rest ofinvalidatesin the order listed. The action has to invalidate it (the default set does; an explicitinvalidatesmust list it), or that is an error: a patch over data that never loads again would have no end. Only the target is patched; other invalidated data just reloads. - The sequence. The patch is shown from the start of the write. On a failure it is removed (the rollback). On a success it stays over the old value until the invalidated data has loaded again, then the server's value replaces it: no frame shows the old value in between. This holds with
keep_previous: falsetoo: while a write that patched the data settles,DataViewdoes not showloading.dart, because the page has already shown the result. - Concurrent writes apply oldest first; a failure removes only its own patch.
- Which reads are patched.
DataView,SectionView(the layout and every page that takes the section's data by type) and the typedXRoute.watch/XSection.watchshow the patched value.XRoute.data(the provider),read,refresh,prefetch,preloadandAppRoutes.dataAtgive the server's value. A dependency-triggered reload (AsyncLoadingwith a value) is returned unpatched bywatch, and still patched inDataView. - The patch holds until the data loads again, even to an equal value: the reload ends it, not a comparison. A patch that throws is reported through
FlutterError.reportError(contextwhile applying an optimistic() patch) and skipped; it never turns intoerror.dart. - If the page leaves in the middle of a write, the write still finishes and still invalidates.
Telemetry (since 0.18.0): when the write fails and its patch is taken back, the action's span says fespalier.action.rolled_back is true; a write that works says false. An action with no shown patch has no such attribute.
Streaming actions: Stream<T>
Since 0.25.0. An action.dart function that returns Stream<T> is a streaming action: a write whose answer comes in pieces, such as a text written word by word (a language model's answer), the progress of an import, or the steps of a long job. Each item is shown as it arrives, and the user can stop it.
// lib/app/docs/new/action.dart
FieldErrors? draftValidate(String input) => input.trim().isEmpty
? const FieldErrors({'topic': 'Type what the doc is about first'})
: null;
/// Each item is the text so far.
Stream<String> draft(Ref ref, {required String input}) =>
ref.read(draftWriterProvider).write(input);// in a HookConsumerWidget (or any ConsumerWidget)
final draft = NewDocRoute.useDraft(ref);
if (draft.isPending)
TextButton(onPressed: draft.cancel, child: const Text('Stop'))
else
TextButton(onPressed: () => draft.call(topic), child: const Text('Draft')),
if (draft.value case final text?) Text(text),- The members are an action's: the provider (
NewDocRoute.draftAction, astreamActionProvider, or astreamActionFamilyfor an action with keys), the one-shot helper (NewDocRoute.draft(ref, input: ...), which completes with the last item, or throws what the stream threw, or aStreamActionCancelledwhen the run was stopped) and the hook (NewDocRoute.useDraft(ref)). - The state is a
StreamActionState<T>, not anAsyncValue(Riverpod has no public "loading, with a value"):StreamIdleuntil it runs,StreamRunningwhile items come, thenStreamDonewhen the stream ended,StreamFailed(witherrorandstackTrace) when it threw, orStreamCancelledwhen the run was stopped. Every state haslatest, the last item of the run (null before the first), andcount, how many came. Each item is a new state, so the page rebuilds on every item, even one equal to the item before it. - The handle (
StreamActionHandle) hasstate,value(state.latest),isPending,hasError,fieldErrors,call(input)(completes with the last item, or withnullwhen the run failed or was stopped: the error, or the stop, is instate, and anullis never a partial result taken for a whole one),cancel()andreset()(stops the run, then idle). validate()runs first, as for any action: an input it refuses never starts the stream, and itsFieldErrorsare the failed state's.FieldErrorsthe stream throws end it the same way.- The last run wins. A
callwhile a run is in progress stops that run (it ends cancelled) and starts the new one; the state is the new run's. - A stopped run is not a result. The
Futureof a run that was stopped before its stream ended (cancel(),reset(), a newer run, or the provider's disposal) fails with aStreamActionCancelled, which carrieslatestandcountfor a caller that wants the partial text, and prints neither (itstoStringsays the count only, since the item can be what a user typed). Sofinal text = await NewDocRoute.draft(ref, input: topic); save(text);never saves a truncated text: catchStreamActionCancelledto treat a stop as routine. A run nobody awaits reports nothing when it is stopped (adraft(ref, ...)you do not await, or the run a newer one replaces). The hook'scallis quiet and never throws. - A page that goes away stops the run. A run started from the hook lives while something watches the provider: when the page is popped, the provider is disposed and the stream's subscription is cancelled, which aborts what it was reading (a
fespalier_streamconnection, or any client that stops when its stream is cancelled). The one-shot helper (draft,submit) holds the provider alive until the stream ends instead, like aFutureaction, so a write that must finish uses it.cancel()stops a held run too. - After the run. When the stream ends, and when a run is stopped after at least one item, the data the action made stale is invalidated (the same default set, or
invalidates). A run that fails after at least one item invalidates too (a partial write is a write); one that fails before its first item invalidates nothing. A run stopped because nothing watches it any more invalidates nothing either: its provider is gone, and a run nothing watches is not finished by a write, so use the one-shot helper (submit), which holds the run alive, for a write that must finish. A streaming action is never retried, and its errors never reacherror.dart. - Spelled any way.
Stream<T>is told by what it names:async.Stream<T>through adart:asyncimport prefix, or a typedef of either (typedef Words = Stream<String>;) in the app's files, is a streaming action too. A nullableStream<T>?is an error. (A typedef of aFuture<T>is still read as a plain value: spell aFutureout.) - Not built. A
form(),const stepsoroptimistic()beside a streaming action is an error that says so: return aFuture<T>for those.validate()is fine. - Within telemetry. The stream is listened to inside the action's span, so an HTTP span made while it runs is its child; what fespalier does with an item or the end (the state it writes, the listeners it wakes, the data it invalidates) runs in your caller's zone instead, so an action a
ref.listenstarts when the state isStreamDoneis not a child of the stream's ended span. The span ends once, withfespalier.action.resultok,errororcancelled, andfespalier.action.items, the count of items (Telemetry conventions); the items are never recorded. In DevTools the run isrunning, thendone(a cancelled run too) orerror, with the last item as its result: one entry per run, not per item. - A hot reload keeps working while the action stays a stream; turning a
Futureaction into aStreamone (or back) changes its provider, sofsp devrestarts the app. - No timer. fespalier starts no timer and no listener for it: the one subscription is the one on the stream your function returned.
UNCHECKED on a device: that popping the page during a run cancels the request on a phone's network, that the keyboard stays usable while the text grows under a field, and how screen readers read a text that changes on every item (a Text that grows may be read again at each change).
The examples/features app drafts a doc this way on /docs/new. Streaming responses in general are on Streaming.