store_scope 0.4.0
store_scope: ^0.4.0 copied to clipboard
Flutter's Jetpack ViewModel — widget-tree-scoped dependency injection with automatic disposal, zero codegen, and bring-your-own reactivity.
store_scope #
Flutter's Jetpack ViewModel — widget-tree-scoped dependency injection with automatic disposal, zero codegen, no global singletons, and bring-your-own reactivity.
📖 中文文档见 README.zh-CN.md
If you've shipped Android or iOS, you already know the mental model: a
ViewModel that's tied to a screen, runs init(), and is cleaned up
automatically when the screen goes away. store_scope brings exactly that to
Flutter — a small DI container scoped to the widget tree — and stops there. It
doesn't pick your reactivity layer, doesn't generate code, and never puts your
objects in a global locator.
final counterProvider = ViewModelProvider<CounterVm>((space) => CounterVm());
class CounterPage extends StatelessWidget with ScopedStatelessMixin {
const CounterPage({super.key});
@override
Widget buildScoped(BuildContext context, StoreSpace space) {
final vm = space.bind(counterProvider); // created here, disposed with this widget
return ValueListenableBuilder<int>(
valueListenable: vm.count,
builder: (_, value, __) => Text('$value'),
);
}
}
Why store_scope? #
Most Flutter state solutions force a package deal: pick the library and you also
inherit its reactivity model, its codegen, or its global state. store_scope
unbundles the one piece that's genuinely hard — scoped lifetime and
dependency injection — and leaves the rest to you.
| Capability | store_scope | Riverpod | get_it | provider | GetX |
|---|---|---|---|---|---|
| Widget-tree-scoped auto-disposal | ✅ | ✅ | ❌ | ⚠️ manual | ⚠️ |
| Real DI container (compose deps) | ✅ | ✅ | ✅ | ❌ type-keyed | ✅ global |
Jetpack-style ViewModel (init/dispose) |
✅ | ❌ | ❌ | ❌ | ⚠️ controller |
| Argument families | ✅ withArgument |
✅ family |
⚠️ params | ❌ | ⚠️ |
| Zero codegen | ✅ | ❌ codegen is the path | ✅ | ✅ | ✅ |
| Reactivity-agnostic | ✅ | ❌ AsyncValue/ref.watch |
✅ none | ❌ ChangeNotifier |
❌ Obx/Rx |
| No global singletons | ✅ | ✅ | ❌ locator | ✅ | ❌ global |
| Test injection / overrides | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ |
store_scope is the only option that ticks all of: tree-scoped auto-disposal
+ a real DI container + a familiar ViewModel + zero codegen +
reactivity-agnostic + no global singletons. Signal/bloc/ValueNotifier fans
keep their reactivity; native developers keep their ViewModel.
Install #
dependencies:
store_scope: ^0.4.0
Quick start #
1. Wrap your app once. The StoreScope owns a Store for everything below
it.
void main() => runApp(const StoreScope(child: MyApp()));
2. Hold state in a ViewModel (or any plain class). Reactivity is yours —
here a built-in ValueNotifier.
class CounterVm extends ViewModel {
final count = ValueNotifier(0);
@override
void init() {
super.init();
addCloseable(count.dispose); // cleaned up automatically
}
void increment() => count.value++;
}
3. Define a provider — its lifetime is declared here, once.
// Scoped: disposed when the last widget that bound it is gone.
final counterProvider = ViewModelProvider<CounterVm>((space) => CounterVm());
4. Bind it to a widget's scope and read reactively.
class CounterPage extends StatelessWidget with ScopedStatelessMixin {
const CounterPage({super.key});
@override
Widget buildScoped(BuildContext context, StoreSpace space) {
final vm = space.bind(counterProvider);
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: vm.count,
builder: (_, value, __) => Text('Count: $value'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: vm.increment,
child: const Icon(Icons.add),
),
);
}
}
When CounterPage leaves the tree, its scope dies, CounterVm.dispose() runs,
and the ValueNotifier is closed. No manual cleanup, no dispose() plumbing in
your State.
Core concepts #
Store & StoreScope #
A Store is a DI container: a map of providers to their live instances. A
StoreScope widget creates and owns one for its subtree; when that widget is
removed, the store unmounts and every instance it holds is disposed.
StoreScope(child: MyApp()); // default store
StoreScope(overrides: [...], child: ...); // with test doubles (see Testing)
StoreScope(storeOwner: myOwner, child: ...);// bring your own store owner
Read the store from any descendant context:
context.store; // the nearest Store (throws if none)
context.storeOrNull; // or null
context.storeMounted; // bool
Providers & lifetimes #
A provider is a recipe for an instance. Lifetime is a property of the definition, not of the call site — you decide once, when you declare it.
// Scoped — reference-counted, disposed when the last binding scope dies.
final repoProvider = Provider.from((space) => Repository());
// Store-lifetime — a lazy singleton kept until the StoreScope unmounts.
final authProvider = Provider.shared((space) => Auth());
| Lifetime | Define with | Acquire with |
|---|---|---|
| Widget / page | Provider.from |
space.bind(p) |
| Feature / flow (spans several pages) | Provider.from |
store.bindWith(p, featureScope) — a DisposeStateNotifier you dispose when the flow ends |
| App | Provider.shared |
store.share(p) / context.share(p) |
Store.share accepts only a store-lifetime provider (it's statically typed to a
SharedProvider), so you can never accidentally read a scoped provider without
a scope.
Acquiring instances: bind vs share #
// Scoped: tracked by `scope`; when the last scope is disposed, so is the instance.
final repo = space.bind(repoProvider);
final repo = store.bindWith(repoProvider, someListenable);
// Store-lifetime: no scope required.
final auth = store.share(authProvider);
final auth = context.share(authProvider);
The return value is optional. When you only want to attach a provider's lifetime to the current scope and never touch the instance, just drop the variable — bind is deliberately not annotated @useResult, so a bare call statement raises no analyzer warning:
space.bind(analyticsProvider); // keep it alive with this scope, nothing else
Binding the same provider to the same scope is idempotent (no extra reference is counted), so calling it on every build is safe. Do not write late final x = space.bind(p); though: a lazy field whose value is never read never runs its initializer, so nothing is ever bound. Keep it an eagerly executed statement.
Arguments (families) #
Provider.withArgument builds a family keyed by the argument's value
(deep-compared, so List/Map/Set arguments work as keys). withArgument
through withArgument6 cover up to six positional arguments.
final userProvider = Provider.withArgument<User, int>(
(space, id) => User(id),
);
final user = space.bind(userProvider(42)); // scoped to this widget
Add .asShared at the definition site to make the family store-lifetime —
one instance per distinct argument, kept until the store unmounts:
final userProvider =
Provider.withArgument<User, int>((space, id) => User(id)).asShared;
final user = store.share(userProvider(42)); // per-id singleton
// userProvider(42) and userProvider(7) are different instances.
A given factory is either scoped (
bind) or shared (.asShared) — chosen once at definition, never mixed at call sites.
ViewModel #
ViewModel is a Jetpack-style holder with deterministic teardown.
class FeedVm extends ViewModel {
final items = ValueNotifier<List<Item>>([]);
Timer? _timer;
@override
void init() {
super.init();
// Auto-cancel a subscription:
addSubscription(repo.stream.listen(_onData));
// Run any teardown callback:
addCloseable(items.dispose);
// Keyed teardown — adding the same key disposes the previous one first:
addKeyedCloseable('poll', () => _timer?.cancel());
_timer = Timer.periodic(const Duration(seconds: 5), (_) => refresh());
}
@override
void dispose() {
// your own cleanup, then:
super.dispose(); // runs every closeable / subscription
}
}
final feedProvider = ViewModelProvider<FeedVm>((space) => FeedVm());
final appFeedProvider = ViewModelProvider.shared<FeedVm>((space) => FeedVm());
init() runs on creation; dispose() (and every registered closeable) runs
when the binding scope dies or the store unmounts. ViewModelProvider also
supports .withArgument* and .asShared, exactly like Provider.
Dependency composition (cascade disposal) #
A provider's body receives a StoreSpace, so it can bind its own
dependencies. Children are bound to the parent instance's scope, which means
they're disposed together with the parent — composition is just plain Dart,
no graph DSL, fully debuggable.
final profileProvider = ViewModelProvider<ProfileVm>((space) {
final user = space.bind(userProvider(42)); // child
final settings = space.bind(settingsProvider); // child
return ProfileVm(user, settings);
});
// When ProfileVm is disposed, `user` and `settings` are released too.
Scopes in the widget tree #
Pick whichever entry point fits the widget you're writing:
// StatelessWidget, get a StoreSpace:
class A extends StatelessWidget with ScopedStatelessMixin {
@override
Widget buildScoped(BuildContext context, StoreSpace space) =>
Text(space.bind(p).label);
}
// StatefulWidget, get a StoreSpace via the `space` getter:
class B extends StatefulWidget { /* ... */ }
class _BState extends State<B> with ScopedStateMixin {
@override
Widget build(BuildContext context) => Text(space.bind(p).label);
}
// Inline, no new class:
ScopedBuilder(
builder: (context, space, child) => Text(space.bind(p).label),
);
There is no third choice to make: if you need the raw Listenable scope instead
of the space — to call context.store.bindWith(p, scope), or to hand the
lifetime to something else — use space.scope (or the scope getter that
ScopedStateMixin also exposes).
AutoStoreWidget owns a fresh Store for its own subtree (handy for a
self-contained page or flow):
class FeaturePage extends AutoStoreWidget {
const FeaturePage({super.key});
@override
Widget build(BuildContext context) {
final vm = context.share(featureVmProvider); // lives as long as this page
return /* ... */;
}
}
Reactivity is yours #
store_scope ships no reactivity. It manages what objects exist and for
how long; how the UI updates is entirely your choice. A few recipes:
ValueNotifier (built in):
class CounterVm extends ViewModel {
final count = ValueNotifier(0);
@override
void init() { super.init(); addCloseable(count.dispose); }
}
ValueListenableBuilder<int>(
valueListenable: space.bind(counterProvider).count,
builder: (_, value, __) => Text('$value'),
);
signals (signals.dev):
class CounterVm extends ViewModel {
final count = signal(0);
@override
void init() { super.init(); addCloseable(count.dispose); }
}
Watch((_) => Text('${space.bind(counterProvider).count.value}'));
flutter_bloc — hold a Bloc/Cubit in a ViewModel:
class CounterVm extends ViewModel {
final cubit = CounterCubit();
@override
void init() { super.init(); addCloseable(cubit.close); }
}
BlocBuilder<CounterCubit, int>(
bloc: space.bind(counterProvider).cubit,
builder: (_, count) => Text('$count'),
);
The pattern is always the same: keep your reactive primitive inside a
ViewModel, register its teardown with addCloseable, and store_scope
guarantees it's released at the right time.
Testing: override providers #
Swap any provider for a fake/mock without touching production code, via
StoreScope(overrides: ...) (widget tests) or StoreImpl(overrides: ...)
(pure-Dart tests).
final repositoryProvider =
Provider.shared<Repository>((space) => RealRepository());
testWidgets('renders fake data', (tester) async {
await tester.pumpWidget(
StoreScope(
overrides: [repositoryProvider.overrideWithValue(FakeRepository())],
child: const MyApp(),
),
);
// ...
});
test('pure Dart', () {
final store = StoreImpl(
overrides: [repositoryProvider.overrideWith((space) => FakeRepository())],
);
expect(store.share(repositoryProvider), isA<FakeRepository>());
});
Pick by intent:
| Goal | Do this |
|---|---|
Test the real ViewModel — its behaviour, init(), cleanup |
Override its dependencies, not the ViewModel:repoProvider.overrideWithValue(FakeRepo()) |
| Drop a ready-made fake into the tree | vmProvider.overrideWithValue(fake) |
The fake needs a StoreSpace, or its init() is where the setup happens |
vmProvider.overrideWith( (space) => Fake(space)..init(), dispose: (vm) => vm.dispose(),) |
The two entry points:
overrideWithValue(fake)— hands the store a ready-made instance. The usual way to inject a mock.overrideWith((space) => fake, dispose: ...)— replaces the creation logic with a factory, so the fake canspace.bind/space.sharedependencies of its own (whichoverrideWithValuecannot).
One rule, no exceptions #
An override hands the store an inert stand-in. The store returns it where
the real provider was requested, and never runs its lifecycle — overriding
a ViewModelProvider does not call the fake's init() or dispose(). You
built the fake, so you own it. There is no flag that changes this.
That's deliberate: a fake is routinely reused — captured in a final at the top
of a test, shared across cases, rebuilt by a factory that runs again whenever the
store needs the instance anew. A container that disposed it would hand out a dead
object on the next round, and the failure would surface far from its cause.
If your fake's hooks matter, run them yourself. For a fake that needs no
StoreSpace, do it before injecting:
final fake = FakeUserVm();
fake.init(); // if the fake's init() sets up state
addTearDown(fake.dispose); // if its teardown matters
StoreScope(
overrides: [userVmProvider.overrideWithValue(fake)],
child: const MyApp(),
);
If the fake's init() is where the work happens — binding dependencies,
starting subscriptions — it needs a live StoreSpace, and that only exists
inside the factory. Run the hooks there instead:
StoreScope(
overrides: [
userVmProvider.overrideWith(
(space) => FakeUserVm(space)..init(), // space is live here
dispose: (vm) => vm.dispose(), // the store runs this on teardown
),
],
child: const MyApp(),
);
That's the full production lifecycle, spelled out in two visible lines instead
of hidden in the container — and because you opted in, the consequences stay in
sight: build a fresh instance in the factory when you use this shape, since
create may run again and re-running init() on a captured singleton would
initialize it twice.
Testing the real lifecycle #
If what you want to verify is that init() ran, that addCloseable /
addSubscription actually cleaned up, or that the ViewModel died with its
scope — don't override the ViewModel at all. Override the dependencies it
binds and let the real one run; it then follows the exact production path,
hooks included:
final repoProvider = Provider.shared<Repo>((space) => HttpRepo());
final userVmProvider = ViewModelProvider<UserVm>(
(space) => UserVm(space.share(repoProvider)), // dependency via the store
);
// Swap the leaf, keep the real ViewModel: init() / dispose() run as usual.
StoreScope(
overrides: [repoProvider.overrideWithValue(FakeRepo())],
child: const MyApp(),
);
Override the ViewModel when you want it out of the test; override its dependencies when you want it under test.
Argument providers match by value:
userProvider(42).overrideWithValue(...)overrides onlyuserProvider(42);userProvider(7)still uses the real one.Overrides are read once when the store is created. To swap them at runtime, give the
StoreScopea newkey(orstoreOwner) so the store is rebuilt.
Lifecycle at a glance #
- Scoped instances are reference-counted across every scope that binds them; the instance is disposed only when the last scope is gone.
- Store-lifetime (
shared) instances are never tied to a widget scope; they live until theStoreScopeunmounts. - Removing a
StoreScopeunmounts its store and disposes every instance — shared and scoped alike. ViewModel.dispose()and alladdCloseable/addSubscription/addKeyedCloseablecallbacks run deterministically at disposal.- Scope-driven teardown is inside-out: when a binding scope dies, the
instance's dependency cascade — everything its creator
space.bind-ed — is released before its owndispose()runs. Don't reach for a bound dependency while shutting down; by then it is already gone. (unmount()runs no cascade at all and disposes in creation order, so the same rule applies there for a different reason — never depend on teardown order during unmount.) - Overrides sit outside all of this. Everything above describes instances
the store created. An injected fake is one you created, so the store only
returns it — it never runs
init()ordispose()on it. Whoever builds the instance owns its lifecycle (see Testing).
API cheatsheet #
StoreScope({child, overrides, storeOwner}) |
Owns a Store for the subtree |
Provider.from((space) => x) |
Scoped provider |
Provider.shared((space) => x) |
Store-lifetime provider |
Provider.withArgument<T, A>(...) |
Argument family (…2–…6) |
factory.asShared |
Make an argument family store-lifetime |
ViewModelProvider<T>((space) => vm) |
Scoped ViewModel (+ .shared, .withArgument*, .asShared) |
space.bind(p) |
Acquire scoped to this space |
store.bindWith(p, listenable) |
Acquire scoped to any Listenable |
store.share(p) / context.share(p) |
Acquire a store-lifetime instance |
p.overrideWithValue(v) / p.overrideWith(...) |
Test doubles |
ScopedStatelessMixin / ScopedStateMixin |
Get a StoreSpace in a widget |
ScopedBuilder |
Inline scoped builder |
AutoStoreWidget / AutoStoreStatefulWidget |
A widget that owns its own store |
DisposeStateNotifier |
A disposable Listenable to use as a custom scope |
Example #
A runnable demo lives in example/. On-device integration tests
covering every feature are in
example/integration_test/ — run them with:
cd example && flutter test integration_test -d <device-id>
Contributing #
Issues and PRs are welcome. The package has no codegen and no runtime
dependencies beyond Flutter and equatable; flutter test runs the unit suite,
and flutter test integration_test (in example/) runs the on-device suite.
License #
Learn more #
- Interactive docs & Q&A: https://zread.ai/z-chu/store_scope
- 中文文档: README.zh-CN.md