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 — a FlutterError whose summary is that one line, with the explanation and the advice under it and the offending dependent named at the bottom, the way the framework reports a mistimed lookup of its own. 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". The registrations one dependent makes on different frames therefore do not add up: the later ones replace the earlier ones, and whatever they replaced stops being told about changes. didChangeDependencies is the everyday way to run into that — it 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.

A builder counts as a build when it re-runs whole. LayoutBuilder and SliverLayoutBuilder — both are a ConstrainedLayoutBuilder, and OrientationBuilder hands its own builder the context of the first — build their subtree on behalf of an element that has no build of its own, and they build all of it every time. The delegate of a SliverPersistentHeader is the same shape: one child, built from scratch whenever the shrink offset changes. Everything such a builder registers is registered again on its next run, so nothing is left behind by the frame it happened to fall on. The context has to be the one the builder is given, though: a closure that captured the context of the widget around it registers on an element that is not being rebuilt at all, and the next relayout wipes whatever that element's own build had asked for. That holds even when the widget around it is another LayoutBuilder — being a builder of the right kind is not enough, it has to be the builder that is running.

The item builder of a lazy list does not count. A lazy list builds a few items at a time, and the ones a scroll brings into view are built on a later frame — all of them registering on the list's own element, so the later items replace what the earlier ones asked for and those items go stale with nothing said. Put a Builder around the item and subscribe from the context it gives you: the registration is then the item's own, and a change wakes that item instead of the whole list. Everything else that is neither a build nor one of the builders above is refused as well — didChangeDependencies and didUpdateWidget alike, under a layout callback or anywhere else.

None of this is checked in a release build. debugDoingBuild and RenderObject.debugActiveLayout are both set inside assertions of Flutter's own, so a release build has no way to tell a build from a timer callback — and the assertion is not compiled into it either. The mistake is silent there: the subscription is replaced by a later one, and the widget stops hearing about the value it selected.

Where Flutter's own dependencies differ

A plain InheritedWidget has no such rule, and State.didChangeDependencies is documented as a safe place to call dependOnInheritedWidgetOfExactType from. It can be, because the dependency it takes is membership and nothing else: the dependent is in the set or it is not, and the set is emptied only when the element is deactivated. There is no per-build boundary to fall outside of, so when the call is made does not matter.

What a scope stores is not membership but a pair (value, selector), and a pair is only true of the build that made it. It has to be replaced build by build, and whatever replaces it has to know which build it belongs to — which is the whole of the rule above.

InheritedModel is the closest thing Flutter has to a selector, and it keeps its aspects forever. InheritedModelElement.updateDependencies adds the new aspect to the ones the dependent already had, and nothing takes any of them away until the element leaves the tree. A widget that asked for a in one build and for b in the next is woken by both from then on, including for the branch it no longer takes; one call with aspect: null marks it as depending on everything, permanently, whatever it selects afterwards. The accumulation is silent — the dependent is simply rebuilt more often than it needs to be, and more often the longer it lives — which is why the timing of the call is not worth an assertion there: nothing about it fails outright.

A scope empties what a dependent asked for at the first registration that arrives on a new frame — which, for a dependent that registers only from a build of its own, is the start of each of its builds — so what wakes it is what its latest build actually selected. That reset is what the rule above pays for: a registration has to say which build it belongs to, and one made from didChangeDependencies, or from a builder that runs a few items at a time, has no answer.

Where provider draws the line

provider solves the same problem and draws two lines, one for each of its two ways to subscribe — which is worth knowing, because the two do not agree with each other and neither of them agrees with the rule above. What follows was read off version 6.1.5+1.

context.watch, and Provider.of with listen: true, are guarded by the frame. The assertion asks whether the build owner is building anything at all, which is the coarse line this package used to draw and gave up: it catches a button callback and a timer, and it lets didChangeDependencies through, because a dependent's hooks run inside the same buildScope as the builds around them. A watch taken there is not refused.

context.select is guarded by the dependent, the way the rule above is: the assertion asks whether the calling element is in its own build, so a select from didChangeDependencies is refused there as it is here. The exception for a layout callback is written into the assertion as a widget type — a LayoutBuilder is let through by name. That covers OrientationBuilder, which is a StatelessWidget wrapped around a LayoutBuilder and hands its builder that element's context; it does not cover SliverLayoutBuilder, which is the other subclass of ConstrainedLayoutBuilder and is refused by a check written against the first. The rule here names the shared superclass instead, so both go through, and a persistent header delegate with them.

The item builder of a lazy list is refused outright, by an assertion of its own whose advice is to wrap the item in a Builder or pull it out into a widget. This package refuses it too, and for a harder reason than the one provider gives. The context a lazy list hands its item builder is the list's own element, in either package, so a select taken on it subscribes the list: a change rebuilds every item the list is holding rather than the one that cares, and that is the cost provider names. Here it is not only a cost. The items are built across frames, and the frame is the boundary this package resets on, so the items a scroll brings into view wipe what the items above them had asked for — and those items are then subscribed to nothing at all. A Builder around the item settles both at once.

What a dependent asked for is cleared on a microtask, not on a build. provider keeps the selectors of one dependent in a set and empties it at the first registration that arrives after a microtask boundary, having scheduled that boundary itself when the set was first filled. The batch it delimits is therefore "everything registered in one synchronous stretch" rather than "this build of this dependent" — which works because select is confined to a build anyway, but it is a different clock from the one the rule above runs on.

A watch cannot be taken back. Subscribing to everything is stored as a dependency that is not a selector set, and the first thing the registration does is return early when it finds one. A dependent that watched a provider once is subscribed to all of its changes for the life of the element, whatever it selects afterwards — the same trap as aspect: null on an InheritedModel. Here, of(..., listen: true) wins over select only inside the build that took it, because the next build starts from an empty slate.

Provider reads Element.dirty too, in its notification path: a dependent already scheduled to rebuild is not worth running selectors for. The comment beside that check says the same thing as the assertion here — that select can never be used inside didChangeDependencies. This package read the same flag until the rule was narrowed, to tell a dependent's own build from a builder it runs on somebody else's behalf; it no longer does, because what it asks now is which builder rather than which phase.

All four in one table:

InheritedWidget InheritedModel provider a scope
what a dependency holds membership a set of aspects a set of selectors, or "everything" a pair (value, selector)
when it is emptied on deactivation on deactivation at the first registration after a microtask at the first registration on a new frame
listening to everything the only mode aspect: null, for good watch, for good wins inside that one build
where it may be taken anywhere, didChangeDependencies included anywhere watch: any build phase; select: the dependent's own build, plus LayoutBuilder by name the dependent's own build, plus a builder that re-runs whole
from the item builder of a lazy list allowed allowed refused; wrap the item in a Builder refused; wrap the item in a Builder

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 -V | 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 -V | 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