pub.dev Version (including pre-releases) License: MIT zread

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 can space.bind / space.share dependencies of its own (which overrideWithValue cannot).

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 only userProvider(42); userProvider(7) still uses the real one.

Overrides are read once when the store is created. To swap them at runtime, give the StoreScope a new key (or storeOwner) 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 the StoreScope unmounts.
  • Removing a StoreScope unmounts its store and disposes every instance β€” shared and scoped alike.
  • ViewModel.dispose() and all addCloseable / addSubscription / addKeyedCloseable callbacks 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 own dispose() 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() or dispose() 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

MIT

Learn more

Libraries

store_scope
store_scope β€” Flutter's Jetpack ViewModel.