scoped_store
Scoped dependency injection for Flutter with reference-counted store
lifecycle. No codegen, no reactive primitive of its own — bring
ChangeNotifier, ValueNotifier, Riverpod, Bloc, signals, anything a
widget can listen to.
What it solves
You've probably shipped this bug before: a store is shared between two screens, the first screen disposes it, the second one keeps trying to read it. Or — the opposite — you keep the store alive forever because "something might still need it" and pay the memory cost.
scoped_store fixes this with reference counting:
retain<T>increments a counter and returns the instance (creating it the first time via a factory).release<T>decrements. When the counter hits zero, the store is disposed automatically.
Every widget that needs a store retains on mount and releases on unmount. The store lives exactly as long as something needs it — no earlier, no later.
Install
dependencies:
scoped_store: ^0.2.1
import 'package:scoped_store/scoped_store.dart';
1. Define a store
Any class. scoped_store doesn't ship a reactive primitive — use
whatever you already use.
class CounterStore implements Disposable {
final count = ValueNotifier<int>(0);
void increment() => count.value++;
@override
void dispose() => count.dispose();
}
Implementing Disposable is optional but recommended — it makes the
intent explicit. Any class with a dispose() method also works via
duck-typing.
2. Retain and read
// Creates on first call, bumps the ref count on subsequent calls.
retain<CounterStore>(() => CounterStore());
// Global shorthand for StoreManager.instance.get<T>():
get<CounterStore>().increment();
// Safe version — returns null instead of throwing:
tryGet<CounterStore>()?.increment();
// Release when done. If this was the last retainer, the store is disposed.
release<CounterStore>();
3. Bind to a widget
You have four widget styles to pick from — they're all equivalent, just different ergonomics for different situations.
StoreBuilder<T> — inline, builder-based
Use when the store is local to one widget tree and you want everything in one place.
StoreBuilder<CounterStore>(
create: () => CounterStore(),
builder: (context, store) => ValueListenableBuilder<int>(
valueListenable: store.count,
builder: (_, value, __) => Text('$value'),
),
);
Omit create: to look up an already-retained store instead of owning
the lifecycle.
OwnedStoreView<T> — subclass, GetX-style
When you want one widget = one screen = one owned store, and you'd rather not nest a builder.
class HomePage extends OwnedStoreView<CounterStore> {
const HomePage({super.key});
@override
CounterStore createStore() => CounterStore();
@override
Widget build(BuildContext context, CounterStore store) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: store.count,
builder: (_, value, __) => Text('$value'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: store.increment,
child: const Icon(Icons.add),
),
);
}
}
Retains in initState, releases in dispose. No boilerplate.
StoreView<T> — read-only, no lifecycle
When a parent already owns the store and this screen only reads it.
class CountLabel extends StoreView<CounterStore> {
const CountLabel({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: store.count,
builder: (_, value, __) => Text('$value'),
);
}
}
Throws if no CounterStore is currently retained. Good for child
widgets deep in a subtree owned by an OwnedStoreView higher up.
StoreStateMixin<T, W> — mix into existing State<W>
When you already have a State<W> doing other work (animations,
focus nodes, scroll listeners) and just want to drop lifecycle in.
class _HomePageState extends State<HomePage>
with StoreStateMixin<CounterStore, HomePage>, TickerProviderStateMixin {
late final AnimationController _bounce =
AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
@override
CounterStore createStore() => CounterStore();
@override
void dispose() {
_bounce.dispose();
super.dispose(); // StoreStateMixin releases here
}
@override
Widget build(BuildContext context) {
return Text('${store.count.value}');
}
}
Same retain-in-initState / release-in-dispose contract as
OwnedStoreView, but composable with other mixins.
Multiple instances by id
When you need N instances of the same type keyed by a runtime value — per user, per document, per tab, per "limit code".
// Retain a per-user cart.
retainById<UserCart>('user-42', () => UserCart('user-42'));
// Get it back from anywhere.
final cart = getById<UserCart>('user-42');
tryGetById<UserCart>('user-42')?.checkout(); // safe variant
// Inspect / release.
StoreManager.instance.getRefCountById<UserCart>('user-42');
releaseById<UserCart>('user-42'); // disposed when 0
All four widget styles support id too:
class CartPage extends OwnedStoreView<UserCart> {
const CartPage({super.key, required this.userId});
final String userId;
@override
String? get id => userId;
@override
UserCart createStore() => UserCart(userId);
@override
Widget build(BuildContext context, UserCart store) => ...;
}
Open CartPage(userId: 'user-42') twice → two retains, one store.
Close both → disposed.
Lazy singletons
For stores that should be created if someone asks — not up-front at startup:
// At app start, register the factory but don't create anything.
registerFactory<ExpensiveStore>(() => ExpensiveStore.loadFromDisk());
// First call constructs it. Subsequent calls return the same instance.
final store = get<ExpensiveStore>();
// Matching release. When refcount hits 0, dispose runs and the slot
// frees up — the factory stays registered for next time.
release<ExpensiveStore>();
This composes with @injectable / get_it factory registrations too —
scoped_store holds its own instance cache, so a get_it factory that
returns fresh instances on every call won't break refcounting.
Stores that own other stores
When FooStore needs to keep BarStore and BazStore alive for its
lifetime, mix in AutoReleaseScopedStore:
class FooStore implements Disposable with AutoReleaseScopedStore {
late final BarStore _bar;
late final BazStore _baz;
FooStore() {
_bar = retainStore<BarStore>(() => BarStore());
_baz = retainStore<BazStore>(() => BazStore());
}
@override
void dispose() {
onDispose(); // releases _bar and _baz in LIFO order
// ... your own cleanup
}
}
Each retainStore call records the matching release callback. When
onDispose() runs, they all fire in reverse order, balancing the
retains. Use retainStoreById<T>(id, ...) for keyed variants.
Why not just get_it?
scoped_store is built on top of get_it and uses it as a fallback
container — you get get_it's speed when you want plain singletons.
What this package adds:
- Reference counting. Two widgets can both "own" a store; the last
one out turns off the lights. Plain
get_ittreats every registration as exclusive — you're responsible for ordering retain/release correctly. - Widget-scoped lifecycle via
StoreBuilder/OwnedStoreView/StoreStateMixin— retain/release happens ininitState/disposeso you can't forget. - Keyed instances (
*ById) withoutget_it's factory-by-name boilerplate. - Own instance cache that's not confused by
@injectablefactory registrations — get_it factories return fresh instances on everyget<T>(), which would break refcounting.
If you only ever register singletons for the entire app lifetime, plain
get_it is perfect and you don't need this. The moment you have stores
that should come and go with navigation, this fits.
API reference
Globals (shorthands for StoreManager.instance.…)
| Function | Purpose |
|---|---|
retain<T>([factory]) |
Create-or-increment. Returns the instance. |
release<T>() |
Decrement; dispose if count hits 0. |
get<T>() |
Lookup; throws if not registered. |
tryGet<T>() |
get that returns null instead of throwing. |
registerFactory<T>(fn) |
Register a lazy factory without creating yet. |
retainById<T>(id, [factory]) |
Keyed retain. |
releaseById<T>(id) |
Keyed release. |
getById<T>(id) |
Keyed lookup. |
tryGetById<T>(id) |
Keyed tryGet. |
StoreManager
| Member | Purpose |
|---|---|
instance |
The singleton. |
getRefCount<T>() / getRefCountById<T>(id) |
Inspect the current ref count (debug / tests). |
reset() |
Dispose everything and clear factories. Tests only. |
instanceId / totalInstancesCreated |
Debug counters — non-1 means hot restart or singleton bypass. |
Widgets / mixins
| Type | Owns lifecycle? | Best for |
|---|---|---|
StoreBuilder<T> |
Yes (if create: given) |
Inline, builder style. |
OwnedStoreView<T> |
Yes | One widget = one screen = one store. |
StoreView<T> |
No (read-only) | Child widgets reading a parent-owned store. |
StoreStateMixin<T, W> |
Yes | Existing State<W> with other concerns. |
AutoReleaseScopedStore |
Yes | A store that retains other stores. |
Disposable |
— | Interface so dispose() runs on release. |
Testing
StoreManager is a process-wide singleton; reset between tests so
state from one test doesn't leak into the next.
setUp(() async {
await StoreManager.instance.reset();
});
test('counter retains and releases', () {
retain<CounterStore>(() => CounterStore());
expect(StoreManager.instance.getRefCount<CounterStore>(), 1);
retain<CounterStore>();
expect(StoreManager.instance.getRefCount<CounterStore>(), 2);
release<CounterStore>();
release<CounterStore>();
expect(StoreManager.instance.getRefCount<CounterStore>(), 0);
});
Gotchas
retain<T>without a factory throws ifTisn't already registered somewhere (scoped_store cache, lazy factory, or get_it container). Either pass a factory, or callregisterFactory<T>(...)first.- Don't mix lifecycle owners for the same
T. If aStoreBuilder/OwnedStoreView/StoreStateMixinalready owns the store, child widgets should useStoreView<T>or plainget<T>()— not retain again, or ref counts won't balance. StoreView<T>throws if the store isn't currently retained. Make sure a lifecycle owner is mounted above it.StoreManager.reset()is destructive — call it fromsetUpin tests, never from app code.- Debug
[StoreManager#N] …logs are gated byassert— they compile out of release builds entirely. AtotalInstancesCreated > 1warning in debug usually means hot restart or someone bypassing the singleton.
License
MIT
Libraries
- scoped_store
- Minimal scoped dependency injection for Flutter.