LiteScope topic
LiteScope
A state class with the whole scope lifecycle behind it, and no dependency
container in front of it. initState, build and setState work as usual;
teardown uses onUnmount and the awaited disposeStateAsync instead of an
override of dispose. There is also asynchronous initStateAsync, plus
notifyDependents, close(), scopeKey and the waiting for child scopes.
This is the family for per-screen state that owns things worth releasing
properly — controllers, subscriptions, a socket. Scope adds a dependency
container in front of the state; AsyncScope drops the state and keeps only
the lifecycle.
final class ScreenScope extends LiteScope<ScreenScope, ScreenScopeState> {
const ScreenScope({super.key, super.scopeKey});
@override
Widget? buildOnWaiting(BuildContext context) => const SizedBox.shrink();
@override
ScreenScopeState createState() => ScreenScopeState();
static ScreenScopeState of(BuildContext context) =>
LiteScope.of<ScreenScope, ScreenScopeState>(context);
}
final class ScreenScopeState
extends LiteScopeState<ScreenScope, ScreenScopeState> {
final controller = ScrollController();
@override
Future<void> disposeStateAsync() async => controller.dispose();
@override
Widget build(BuildContext context) =>
ListView(controller: controller, children: const [Text('item')]);
}
The state
LiteScopeState is a State with five additions:
| member | what it is |
|---|---|
initStateAsync() |
asynchronous initialization, started once the state exists |
onUnmount() |
synchronous teardown, always before disposeStateAsync() |
disposeStateAsync() |
asynchronous teardown, awaited before the scope is gone |
notifyDependents() |
rebuild the subscribed descendants, not the subtree |
close() |
run the teardown while the scope is still on screen |
and three of its own properties: params — the scope widget, so its
constructor parameters are readable from the state; isInitialized; and
onInitialized(), the hook called once the initialization has fully completed.
State.widget is not among them, and it never will be: a scope state has no
widget of its own — params is the scope widget, and that is the whole answer.
Reading widget throws an UnsupportedError saying so, which is what a
State mixin written for ordinary widgets runs into.
Ticker mixins need a separate owner. SingleTickerProviderStateMixin and
TickerProviderStateMixin on the scope state cannot release an active
AnimationController in onUnmount(): on ordinary removal Flutter unmounts
descendants before ancestors, so the scope state's element runs the mixin's
dispose check before the parent scope element calls onUnmount(). The result
is was disposed with an active Ticker, and the analyzer does not warn about
it.
Let an ordinary child State own the animation and its ticker mixin, and
release the controller before super.dispose(). Return const AnimatedMark()
from the scope state's build, for example; this owner works both on ordinary
removal and when close() takes down the ready subtree:
class AnimatedMark extends StatefulWidget {
const AnimatedMark({super.key});
@override
State<AnimatedMark> createState() => _AnimatedMarkState();
}
class _AnimatedMarkState extends State<AnimatedMark>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
}
@override
void dispose() {
_controller.dispose(); // Before the mixin checks for active tickers.
super.dispose();
}
@override
Widget build(BuildContext context) => FadeTransition(
opacity: _controller,
child: const FlutterLogo(),
);
}
The ordinary initState of a State still works and still runs synchronously,
and initStateAsync is where an await belongs. disposeStateAsync is what
makes a parent scope — and close() — wait for the release to finish rather
than fire and forget it.
dispose is the one to be careful with. It belongs to Flutter, not to the
scope, and it is not part of the teardown order below: when the scope is
removed from the tree the framework calls it before the scope's own teardown
even begins, and after a close() it does not run until the tree comes down —
which may be much later, or never while the closing screen is on show. So the
synchronous half of a scope's teardown goes in onUnmount(), which the scope
itself drives:
@override
void onUnmount() {
_subscription.cancel(); // must stop reaching this scope, now
super.onUnmount();
}
@override
Future<void> disposeStateAsync() => _connection.close(); // may take its time
onUnmount() runs exactly once and always before disposeStateAsync(),
whichever way the scope goes. The BuildContext is gone by the time it runs on
a removed scope, so it may only touch what the state holds in its own fields.
Both halves run after an initStateAsync() that threw, in the same order
as after one that succeeded — so a disposer has to expect a partially
initialized state: a field the await never reached is still unset when it
runs. A failed initialization is not one that never happened. An initializer
that opened a connection and threw on the next line has opened it, and nothing
else is holding it: the scope never becomes ready, so its owner is never handed
the state either, and this hook is the only release there is. Release therefore
belongs here rather than in front of every throw — what it must not do is
assume the initializer got to the end.
Two initializations
There are two phases, and they are not the same thing.
LiteScope.initScope() on the widget is a pre-initialization: an ordinary
async function taking a ScopeInitContext, exactly as in the AsyncScope
topic, running before the state is created. Its default body is empty — ready
at once — which is why most scopes never override it. Override it when
something has to be ready before createState, and then buildOnProgress and
buildOnError have to be overridden too — their default implementations throw
UnimplementedError, on the reasoning that a progress branch nobody wrote is a
mistake rather than a blank screen.
LiteScopeState.initStateAsync() on the state is the usual one, and it
runs after the state exists — which is why nothing waits for it. The state
is created by the ready branch, so by the time this can start, the ready branch
has already built: the first build of the state runs before initStateAsync
has finished, and so do the ones any change asks for in the meantime.
That makes isInitialized part of writing the state rather than a detail: a
late field assigned after an await and read straight from build throws a
LateInitializationError on that first build. Hold the branch back yourself
and rebuild it from onInitialized():
final class TitleScope extends LiteScope<TitleScope, TitleScopeState> {
final Future<String> Function() loadTitle;
const TitleScope({super.key, required this.loadTitle});
@override
Widget buildOnWaiting(BuildContext context) => const SizedBox.shrink();
@override
TitleScopeState createState() => TitleScopeState();
}
final class TitleScopeState
extends LiteScopeState<TitleScope, TitleScopeState> {
late String _title;
@override
Future<void> initStateAsync() async => _title = await params.loadTitle();
@override
void onInitialized() => setState(() {});
@override
Widget build(BuildContext context) {
if (!isInitialized) {
return const Center(child: CircularProgressIndicator.adaptive());
}
return Text(_title);
}
}
Completion calls onInitialized() and notifies subscribed descendants; it does
not rebuild this state's own build. The setState above schedules that
rebuild, so the screen can show the loaded title. onInitialized() runs only
while the state is still on the tree.
Alternatively, give the field a value it can be read with before the
initialization replaces it; call setState when the state's own screen needs
to show the change. Put whatever has to be ready before anything is shown
into initScope() on the widget instead, which is the phase that does hold the
ready branch back.
buildOnWaiting covers the gap before either of them has produced anything —
the frames spent waiting for a scopeKey and for the first event. Returning
null from it is allowed only when buildOnProgress is overridden, since
something has to be on screen.
That is why buildOnWaiting is the one builder a LiteScope must write, which
is the other way round from Scope and the asynchronous families, where
buildOnProgress is the required one. The rule behind both is the same:
exactly one branch before the ready one has to be written, and it is the one
the family is certain to reach. A Scope always initializes a container, so
it always has a progress branch and may skip the waiting one; a LiteScope
initializes nothing of its own, so what it always has is the wait. Moving a
screen from Scope to LiteScope therefore trades one required builder for
another: buildOnProgress and buildOnError become optional — keep them only
if you override initScope() — and buildOnWaiting becomes required.
wrapState wraps the ready branch alone, so a widget every branch needs is
built inside each builder instead.
Access from the subtree
ScreenScope.of(context).controller; // the state
LiteScope.select<ScreenScope, ScreenScopeState, int>( // one value from it
context,
(state) => state.itemCount,
);
LiteScope.paramsOf<ScreenScope, ScreenScopeState>(context, listen: false);
LiteScope.selectParam<ScreenScope, ScreenScopeState, Object?>(
context,
(widget) => widget.scopeKey,
);
of and maybeOf return the state and never subscribe — they are for calling
methods. select subscribes to one value derived from the state, and the
caller is rebuilt only when that value changes and only when the state
called notifyDependents(). Nothing else in the state is observed: a field
mutated without that call reaches nobody.
setState is the other half, and nothing here takes it away — a scope state is
an ordinary State and keeps it. The two do not overlap, which is the part an
analogy with StatefulWidget gets wrong: notifyDependents() rebuilds the
subscribers and leaves this state's own build unrun, while setState
rebuilds what this state's build returns and reaches no subscriber. A field
both sides read wants both calls; a field only one side reads wants only its
own.
paramsOf and selectParam do the same for the scope widget's own parameters.
Re-exposing all of this as named statics on the scope, as of above, is the
usual practice.
close()
await ScreenScope.of(context).close();
close() starts the teardown while the scope is still on screen, and completes
when the disposal is over. The ready subtree is frozen into a screenshot,
buildOnClosing is shown on top of it, and the element stays mounted until the
end — which is the point: a scope that takes a noticeable time to release
things shows "closing…" instead of freezing on its last frame. The disposal
waits until the screenshot has replaced the live subtree and that subtree has
been unmounted, so an ordinary child State can still use what the scope state
owns in its own dispose.
AsyncScope, AsyncDataScope and AsyncControllerScope have the same
close(), without the screenshot: their closing branch replaces the subtree
outright — see close() in the AsyncScope topic. The screenshot is what this
family adds, the last frame kept under buildOnClosing instead of giving way
to it.
The screenshot is best-effort in a precise sense: it is installed only when the
scope is actually ready and still mounted, because in any other state nothing
would ever release the barrier and the future would hang. A subtree that is
never painted — inside an Offstage, or in the unselected branch of an
IndexedStack — cannot be captured either; after
ScreenshotReplacer.maxRetries frames close() proceeds, and the ready
subtree is taken away all the same, with nothing in the picture's place. It has
to be: the whole point of waiting for the screenshot is to let go of what the
subtree holds, and a scope left standing there keeps its own child scopes
mounted and registered — this one would then wait out its
waitForChildrenTimeout for a child nobody had taken away, and release what
that child is still reading.
A closing build that fails does not hold the teardown up either. The barrier is
released by the ScreenshotReplacer that build mounts, so a wrapState or a
buildOnClosing that throws used to mean a close() that never completed and
four stages that never began. Such a failure now surfaces as the ErrorWidget
any failing build turns into, and the teardown behind it goes on.
The default closing overlay reads its colour from the theme above the
scope, which for a scope at the root of the application — one whose wrapState
builds the MaterialApp — is ThemeData.fallback(). Write buildOnClosing
there, which is what a root scope wants anyway.
Calling close() twice does not restart anything: the second call joins the
disposal already running rather than installing a second barrier.
The teardown, and what waits for it
The order is the one from the AsyncScope topic, with the state's own steps in
it: onUnmount() of the state runs first, the pre-initialization is cancelled,
the child scopes are awaited (waitForChildrenTimeout), disposeStateAsync()
of the state runs, and the scopeKey is released last so that the next scope
with that key starts only when this one is finished.
Flutter's own dispose() of the state sits outside that sequence, on either
side of it depending on how the scope went: before all of it when the tree took
the scope down, after all of it when the scope closed itself. That is the whole
point of close() — release first, leave later — and it is why the teardown
the scope guarantees is the onUnmount()/disposeStateAsync() pair rather
than dispose().
A LiteScope is an AsyncScopeParent like every asynchronous scope: the
scopes below it register with it, and it waits for them before disposing of
itself. Asking for that wait from a subtree is
AsyncScopeCoordinator.waitForChildren — the mixin itself sits on the element,
which is private here; see the AsyncScope topic.
Where to go next
| topic | what it covers |
|---|---|
AsyncScope |
the lifecycle in full: states, teardown order, scopeKey, the coordinator |
Scope |
a dependency container in front of the same state |
ScopeWidget |
what notifyDependents does to the subtree |
debug |
the observer that reports every step, and the timeouts |
Classes
-
LiteScope<
W extends LiteScope< LiteScopeW, S> , S extends LiteScopeState<W, S> > - A base class for creating lite scopes without dependency management payload.
-
LiteScopeAccess<
W extends LiteScope< LiteScopeW, S> , S extends LiteScopeState<W, S> > - The five accessors of one LiteScope, with its type arguments named once.
-
LiteScopeCore<
W extends LiteScopeCore< LiteScopeW, E, S> , E extends LiteScopeElementBase<W, E, S> , S extends LiteScopeCoreState<W, E, S> > - A core abstract class for lite scopes, providing minimal dependency injection and state management.
-
LiteScopeCoreState<
W extends LiteScopeCore< LiteScopeW, E, S> , E extends LiteScopeElementBase<W, E, S> , S extends LiteScopeCoreState<W, E, S> > - The core state base class for LiteScopeCore.
-
LiteScopeElementBase<
W extends LiteScopeCore< LiteScopeW, E, S> , E extends LiteScopeElementBase<W, E, S> , S extends LiteScopeCoreState<W, E, S> > - The core element base class for LiteScopeCore.
-
LiteScopeState<
W extends LiteScope< LiteScopeW, S> , S extends LiteScopeState<W, S> > - The state implementation for LiteScope.