AsyncDataScope topic
AsyncDataScope
AsyncScope that produces a value. The initialization ends with the object it
built, the subtree reads it from the scope, and the disposal receives it back.
Everything else — the states, the ordered teardown, scopeKey, the waiting for
child scopes — is the AsyncScope topic, and this page only covers what the
value changes.
Reach for it when the scope exists because of an object: a database handle, a
socket, a decoded file. When the objects are already reachable and only their
lifecycle matters, AsyncScope is one type parameter lighter; when there are
several of them with an order and a teardown each, the Scope topic has the
dependency container.
AsyncDataScope<Database>(
initData: (context, ctx) async {
ctx.progress('opening the database');
return Database.open();
},
disposeData: (database) => database.close(),
progressBuilder: (context, progress) => Text('$progress'),
errorBuilder: (context, error, stackTrace, progress) => Text('$error'),
builder: (context, database) => DatabaseView(database: database),
);
AsyncDataScopeBase is the subclassable form, with initData, disposeData,
onMount, onUnmount and the four builders as overrides.
The value in each place
returning `await Database.open()`
is where the value enters. The element stores it as the ready state is applied, and from that moment on:
builderreceives it as its second argument — the ready branch never has to check fornull;disposeDatareceives it too, and runs only if the initialization succeeded, so the argument always exists;onUnmountreceivesT?instead: it fires the moment the scope leaves the tree, which may be long before there is a value, or after a failure;- descendants read it from the context (below).
The progress side is typed loosely on purpose: ctx.progress takes an
Object?, and the builders receive it as Object?. The value being built is
what the type parameter is for; the progress is a caption.
A value that never arrives still needs a release
Returning is the handover, and until it happens the value belongs to the
initialization alone. The scope has never seen it, so it cannot release it:
after a failure disposeData is not called at all, and onUnmount is handed
null.
That is what makes this family's version of the trap easy to walk into — the value exists, in a local variable, and looks as though the scope is looking after it:
// Wrong: the database is open, and `disposeData` will never be called with it.
initData: (context, ctx) async {
final database = await Database.open();
ctx.progress('migrating');
await database.migrate(); // throws
return database;
},
disposeData: (database) => database.close(),
// Right: whatever ends the body early leaves the database mine to close.
initData: (context, ctx) async {
final database = await Database.open();
try {
ctx.progress('migrating');
await database.migrate();
} on Object {
await database.close();
rethrow;
}
return database;
},
disposeData: (database) => database.close(),
One catch covers both ways this initialization ends early: a failing step,
and a cancellation — the scope removed from the tree, or close()d, before it
was ready. The second arrives as Cancelled, re-exported by scopo, thrown by
a checkpoint — here the ctx.progress above the migration.
The context can own that guard too. Use wait with discard: for an
acquisition that can be left in flight: cancellation ends the waiting, the
action runs on, and discard closes a database the body never receives. join
keeps waiting for a migration that must finish before the database can close:
initData: (context, ctx) async {
final database = await ctx.wait(Database.open, discard: (db) => db.close());
ctx.progress('migrating');
await ctx.join(database.migrate);
ctx.disown(database); // disposeData takes over the release on return
return database;
},
disposeData: (database) => database.close(),
disown stands beside the return, with no wait or checkpoint between them: the
scope releases a late return too, so leaving the job's registration in place
would close it twice. The AsyncScope topic has the rule in full, including
dispose: for a resource that must be released even on success.
A value the body produces after the cancellation is handed to disposeData
rather than lost, while the scope's teardown is still waiting. That keeps a
bare call right for a short initialization that never asks ctx anything: it
merely runs to its end for a scope that is already gone. Once an expired
initCancellationTimeout has let the teardown finish, the scope no longer has
the widget to read disposeData from. Cleanup registered with the job does not
depend on that hook.
Two ways to avoid writing the guard at all: build the value in one step that
cannot fail halfway, or use the dependency container of the Scope family,
which does exactly this bookkeeping for a whole tree of resources. And if the
value is an object with a lifecycle of its own rather than a plain resource,
AsyncControllerScope disposes of it on every path, including the one where
its init() threw.
Why builder is handed the value, when ScopeModel's is not
ScopeModel.builder takes a context and nothing else, and the widgets below it
reach the model through of or select. The difference is not an oversight in
either family; it follows from what the two objects are.
A model lives as long as the scope and changes while it does, so what matters
about it is when somebody reads it: a widget that reads it through select
is subscribed to the part it read and is rebuilt when that part changes.
Handing the model to the builder would give it an unsubscribed reference and
encourage reading the whole object where a selector would do.
A value here is produced once, by initData, and never replaced. There is
nothing to subscribe to and nothing to miss, and there is exactly one branch in
which it exists at all — the ready one, which is what builder builds. Passing
it is what makes that branch free of data! and isInitialized, and it is the
same reason dispose(data) and unmount(T? data) receive it.
Reading it from the subtree
final database = AsyncDataScope.of<Database>(context, listen: false).data;
of, maybeOf and select return an AsyncDataScopeContext, which adds
three members to everything AsyncScopeContext has:
| member | before the value arrives | after |
|---|---|---|
data |
throws StateError('Not initialized') |
the value |
dataOrNull |
null |
the value |
hasData |
false |
true |
A widget under builder is by definition below a ready scope and can use
data. A widget that may also be built while the scope is still initializing —
one in progressBuilder, or one reached from elsewhere in the tree — should
use dataOrNull, or check hasData first.
For a nullable T — AsyncDataScope<Session?> — dataOrNull cannot answer
the question at all: it is null on both sides of the moment the value
arrives, since null is a value the initialization may legitimately produce.
hasData is the difference, and data is the other way of asking: it throws
while there is nothing and returns the null once there is. The same ambiguity
reaches onUnmount, which is handed a T? and cannot tell the two apart on
its own.
"Before the value arrives" is a shade earlier than isInitialized. The value
is stored once the job has finished its children and cleanup successfully; the
state of the scope is applied at the end of the frame, or after the whole of
pauseAfterInitialization, which is deliberately longer. In that window the
scope is still building progressBuilder while data already answers — and
that is the window the teardown of a scope that left early runs in, which is
why disposeData can be promised the value at all.
select is the cheap way in when only a part of the value matters:
final name = AsyncDataScope.select<Profile, String>(
context,
(scope) => scope.data.name,
);
The type arguments read the way they do everywhere else in the package — the
scope's own type first, the selected type last. The selector receives the
context rather than the data, so it can reach dataOrNull or hasError as
well.
What the value does not do
Storing the value does not make it observable. AsyncDataScope notifies its
dependents when its state changes — waiting, progress, ready, error — and
not when something inside the value changes. A Database that gains rows
notifies nobody.
That is the same trade ScopeModel makes, and the ways out are the same: make
the value a Listenable and put a ScopeNotifier under this scope, or expose
a stream from it and let the widgets that care listen.
In the debugger
The element adds the value to its diagnostics, so the widget inspector and
debugDumpApp() show data: Database(…) next to the scope — null until the
initialization succeeds, which makes the two states easy to tell apart when a
subtree does not look the way it should.
Where to go next
| topic | what it covers |
|---|---|
AsyncScope |
the states, the ordered teardown, scopeKey, the coordinator |
Scope |
several dependencies with an order, a progress per dependency, and a state class |
ScopeNotifier |
making the value itself observable |
base |
of, select, listen |
Classes
-
AsyncDataScope<
T extends Object?> AsyncDataScope -
AsyncDataScopeAccess<
W extends AsyncDataScopeBase< AsyncDataScopeW, T> , T extends Object?> - The three accessors of one AsyncDataScopeBase, with its type arguments named once.
-
AsyncDataScopeBase<
W extends AsyncDataScopeBase< AsyncDataScopeW, T> , T extends Object?> -
AsyncDataScopeContext<
W extends ScopeInheritedWidget, T extends Object?> AsyncDataScope -
AsyncDataScopeCore<
W extends AsyncDataScopeCore< AsyncDataScopeW, E, T> , E extends AsyncDataScopeElementBase<W, E, T> , T extends Object?> -
AsyncDataScopeElementBase<
W extends AsyncDataScopeCore< AsyncDataScopeW, E, T> , E extends AsyncDataScopeElementBase<W, E, T> , T extends Object?> -
AsyncDataScopeInitState<
P extends Object, T extends Object?> AsyncDataScope -
AsyncDataScopeProgress<
P extends Object, T extends Object?> AsyncDataScope -
AsyncDataScopeReady<
P extends Object, T extends Object?> AsyncDataScope