base topic
base
Every scope family of this package — ScopeWidgetBase, ScopeModel,
ScopeNotifier, AsyncScope, AsyncDataScope, AsyncControllerScope,
LiteScope and Scope — stands on the same three types. Together they say what a scope is to the widget
tree: an InheritedWidget that can be found from below, an element that owns
whatever the scope holds, and one lookup protocol shared by all of them.
| type | what it is |
|---|---|
ScopeInheritedWidget |
the InheritedWidget every scope widget descends from |
ScopeContext |
the lookup protocol: of, maybeOf, select |
ScopeInheritedElement |
what the element of a scope has to provide |
An application never instantiates them. The layer is still worth reading twice
over: it is where the difference between of, select and listen: false is
decided — identically in every family — and it is the contract to implement
when writing a scope of your own.
The widget
abstract base class ScopeInheritedWidget extends InheritedWidget {
final Object? tag;
const ScopeInheritedWidget({super.key, this.tag, super.child});
}
tag names one particular scope. Two scopes of the same type are otherwise
indistinguishable in the output, where an untagged scope appears as
CounterScope(#4e0b7) and a tagged one as CounterScope(cart) — see the
debug topic for the format. Every family forwards a tag parameter to this
constructor, so any scope can be tagged.
The child needs a word of warning, because it is not what a scope displays. A
plain InheritedWidget wraps a subtree passed to it; a scope builds its own
through buildChild(), which is where the waiting, initializing, error and
closing branches of the richer families come from. The constructor still
accepts a child, so a family that wants the plain behaviour can pass one and
use it from buildChild(); nothing in the package does. The default is a
placeholder that refuses to create an element at all — if a scope ever builds
that child instead of its own, the failure is immediate rather than subtle.
The element
abstract interface class ScopeInheritedElement<W extends ScopeInheritedWidget>
implements ScopeContext<W> {
W get widget;
@mustCallSuper
void init();
@mustCallSuper
void dispose();
Widget buildChild();
}
init() runs once, after the element is mounted and before its first
buildChild. If it throws, that is the end of the scope: the hook is not
attempted again, the scope shows an error instead of its subtree, and every
later build reports the same failure. dispose() runs when the element is
unmounted, and it runs after a failed init() too — an attempt that gave up
halfway may already hold something, and this is where it is given back, so a
family disposer has to expect a partially initialized scope. Both are
@mustCallSuper: a family that overrides them extends the lifecycle rather
than replacing it. The mounted element is connected to its ancestors, so an
init() hook may look one up with listen: false; subscribing from the hook
is not supported and an assertion says so. Everything a scope owns — a model, a
notifier subscription, a dependency container, a place in the queue of a
scopeKey — is acquired in the first and released in the second.
The element is also the ScopeContext of its own scope: what a descendant
receives from of is this object, which is why select can read the current
value straight from it.
The bookkeeping behind all of that — the per-dependent subscriptions, the
rebuild that only notifies instead of rebuilding the subtree — lives in
ScopeWidgetElementBase, described in the ScopeWidget topic. Implementing
ScopeInheritedElement from scratch is not the intended path; extending that
class is.
Finding a scope
Three entry points, all static on ScopeContext, where W is the widget type
and C is the context type of the family:
ScopeContext.maybeOf<W, C>(context, listen: false); // C?, null if not found
ScopeContext.of<W, C>(context, listen: false); // C, throws if not found
ScopeContext.select<W, C, V>(context, selector); // V, throws if not found
In practice these are called through the wrapper each family exposes, so that the type arguments stay short and correct:
final config = ScopeWidgetBase.of<ApiConfig>(context, listen: false);
final apiKey = ScopeWidgetBase.select<ApiConfig, String>(
context,
(widget) => widget.apiKey,
);
The search itself is getElementForInheritedWidgetOfExactType<W>(): ancestors
only, and the exact type — a scope declared as class CartScope extends ShopScope is not found by asking for ShopScope. Looking a scope up never
rebuilds anything by itself; what a caller subscribes to is decided by the
argument below.
listen, and what it costs
listen: false looks the scope up and subscribes to nothing. The caller is
never rebuilt because of that scope. This is what code outside build wants —
a button handler reaching for a service, a callback reading the current state
once:
onPressed: () => ScopeModel.of<Cart>(context, listen: false).clear(),
listen: true subscribes to every change of that scope. The dependent is
rebuilt whenever the scope notifies, whether or not anything it reads has
changed.
select subscribes to one value and is the reason a scope can serve a large
subtree cheaply. It has no listen parameter — selecting is listening — and no
maybe variant: a missing scope is an error rather than a null.
What select actually does
final userName = ScopeModel.select<Session, String>(
context,
(session) => session.userName,
);
The selector runs immediately, and the pair (value, selector) is stored as
the dependent's subscription. When the scope later notifies, every stored pair
of every dependent is re-evaluated: the dependent is rebuilt only if
selector(scope) != value for at least one of them. A widget that selected
userName sleeps through a change of cartTotal.
Five consequences are worth keeping in mind.
The comparison is !=, so the == of the selected value decides
everything. Select a field, a record or an immutable value. A selector that
builds a fresh List or a new object on every call compares unequal every
time, and the widget rebuilds as if it had never selected at all.
Selectors accumulate. Several select calls in one build create several
subscriptions, and a change in any of them rebuilds the widget once. Reading
three fields of a model is three selects, not one selector returning three
values in a list — see above for why the list would be worse.
of(..., listen: true) wins over any select in the same build. It
subscribes to everything, and a subscription to everything cannot be narrowed
by adding a selector to it — in either order, the widget ends up rebuilt on
every notification. Use one or the other for a given scope in a given build.
The captured value is refreshed on every build. Subscriptions are re-established while the dependent builds, as they are anywhere in Flutter, so the pair a scope compares against is the one from the dependent's latest build, not from its first.
A subscription may only be taken from a build, and an assertion says so.
What a dependent asked for is remembered per build, and the boundary between
one build and the next is taken from the frame — Flutter offers no hook for
"this dependent is about to build". A registration made outside a build
therefore belongs to whichever build shares its frame, and is dropped by the
first build that does not: didChangeDependencies runs in the same frame as
the build after it, so a select there looks like it works and then
disappears on the first rebuild that comes from the parent rather than from a
change. To react to a change rather than to show it, keep the subscription in
build and look the scope up with listen: false from
didChangeDependencies.
The builder of a LayoutBuilder counts as a build, and so do those of
OrientationBuilder and SliverLayoutBuilder: they run from performLayout,
inside a build of their own element, and what they return is that element's
subtree. select there is honoured like any other, and the assertion lets it
through. The one place it can no longer tell the mistake from the pattern is a
didChangeDependencies of a widget that is itself under a layout callback.
Depending on itself
A scope element may subscribe to its own scope — that is how the richer
families rebuild their own subtree as the initialization advances.
InheritedElement forbids it (an assert in notifyClients blocks a self
dependency), so those subscriptions are kept apart from the rest and notified
separately. The mechanism belongs to ScopeWidgetElementBase; it matters here
only as the reason ScopeInheritedElement is an interface a family implements
rather than a mixin an application applies.
Errors
Both failures are plain exceptions carrying the type that was asked for:
Exception: CounterScope not found in the context
of and select did not find the widget above the context. Either the scope
is genuinely not there, or the context belongs to a widget above it rather than
below — the usual mistake being a lookup from the very build that installs
the scope. maybeOf returns null in the same situation and is the right call
when absence is expected.
A lookup with listen: true that found nothing is still remembered as a
dependency, exactly as Flutter's own dependOnInheritedWidgetOfExactType would
remember it. A widget that asked when there was no scope above it and is later
carried under one — by a GlobalKey — is therefore told its dependencies have
changed and asks again.
Exception: The element of ScopeModel<Counter> is not ScopeModelContext<ScopeModel<Counter>, Counter>
The widget was found, but its element is not the context type the call asked for. This is a mismatch of type arguments — a family's accessor used against a scope of a different family.
Accessors and editor templates
Every family finds its scope through statics that take the family's type
arguments — Scope.select<App, AppDependencies, AppState, V>(context, selector)
and its four neighbours. Written out as wrappers on the scope, that is the
triple repeated five times per scope.
Each family also ships an accessor object that takes those arguments once:
final class App extends Scope<App, AppDependencies, AppState> {
static const access = ScopeAccess<App, AppDependencies, AppState>();
}
final counter = App.access.select(context, (state) => state.counter);
It is a forwarder and nothing more — every method is the static of the same
name — so the two are interchangeable, and a scope that wants accessors under
its own names still writes them. The README of the package has the table of
all eight.
A template answers the same cost the other way round. Its skeletons write the
accessors out as statics of the scope — App.select(context, …) at every call
site, with nothing to type, because the template typed them. The two are not
rivals: one is for code written by hand, the other for code written by a key
stroke. They ship with the package: ide/scopo.code-snippets for VS Code (and Cursor, Windsurf, Antigravity)
and ide/scopo-live-templates.xml for IntelliJ and Android Studio, both in the
package directory alongside lib/. Eleven templates — one per family, two for a dependency container (automatic
and hand-written), one for the accessor line. Each writes out every class the
shape needs, in one paste, for you to split across files as you like:
"scopo: The accessor object": {
"scope": "dart",
"prefix": "scopo-access",
"body": ["static const access = ScopeAccess<${1:Widget}>();$0"]
}
Installing them. They ship with the package, so they are already on the machine. For VS Code — and Cursor, Windsurf and Antigravity, which share the format — copy the snippets into the project:
mkdir -p .vscode
cp "$(find ~/.pub-cache/hosted/pub.dev -maxdepth 1 -name 'scopo-*' | sort | tail -1)"/ide/scopo.code-snippets .vscode/
For IntelliJ IDEA and Android Studio there is no import button on the Live
Templates page any more. The file goes into the configuration directory of the
IDE, under templates/, named after the group it declares — the XML says
scopo, so the file is scopo.xml — and the IDE is restarted:
# Android Studio on macOS; for IntelliJ IDEA the directory is
# ~/Library/Application Support/JetBrains/<product>/
DIR=~/Library/Application\ Support/Google/AndroidStudio<version>/templates
mkdir -p "$DIR"
cp "$(find ~/.pub-cache/hosted/pub.dev -maxdepth 1 -name 'scopo-*' | sort | tail -1)"/ide/scopo-live-templates.xml "$DIR/scopo.xml"
The group then appears under Settings → Editor → Live Templates.
The rest is in
ide/README.md:
the table of all eleven and what is checked about them. The skeletons they
insert are compiled by the package's own gate, and the suite holds each live
template to the context it belongs in; what an editor makes of the file is
still something only an import shows, and Android Studio took it.
Where to go next
| topic | what it covers |
|---|---|
ScopeWidget |
the element base every family extends: subscriptions, notify-only rebuilds |
ScopeModel, ScopeNotifier |
scopes that own a plain object or a Listenable |
AsyncScope, AsyncDataScope |
asynchronous initialization and disposal |
AsyncControllerScope |
a scope whose whole content is a controller with a lifecycle of its own |
LiteScope |
a scope without a dependency container |
Scope |
the full family: dependencies, state, and the four build branches |
debug |
the observer, timeouts and what a tag looks like in the output |
Classes
-
ScopeContext<
W extends ScopeInheritedWidget> base -
ScopeInheritedElement<
W extends ScopeInheritedWidget> base - ScopeInheritedWidget base