scoped_store 0.2.2
scoped_store: ^0.2.2 copied to clipboard
Minimal reactive-store + scoped dependency injection for Flutter. Retain and release stores by reference count, wire them into widgets with one line, rebuild on value change. Five files, zero codegen.
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