scoped_store 0.1.0
scoped_store: ^0.1.0 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. Three files, no codegen, no opinions about how your state updates.
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).release<T>decrements. When the counter hits zero the store is disposed and unregistered 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.1.0
import 'package:scoped_store/scoped_store.dart';
Three-step example #
1. Define a store #
Any class. Use whatever reactive primitive you like — ChangeNotifier,
ValueNotifier, Riverpod, Bloc, signals. This package doesn't care.
class CounterStore implements Disposable {
final count = ValueNotifier<int>(0);
void increment() => count.value++;
@override
void dispose() => count.dispose();
}
Implementing Disposable is optional but recommended — StoreManager calls
dispose() automatically when the last retainer releases. (Any class with a
dispose() method also works via duck-typing; Disposable just makes the
intent explicit.)
2. Retain and read #
// Registers + creates the first time. Subsequent calls bump the ref count.
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 subtree #
StoreBuilder retains on mount, releases on dispose. Pair it with any
reactive builder — ValueListenableBuilder, ListenableBuilder, Riverpod,
etc.
StoreBuilder<CounterStore>(
create: () => CounterStore(),
builder: (context, store) => ValueListenableBuilder(
valueListenable: store.count,
builder: (_, value, __) => Text('$value'),
),
);
Omit create: to look up an already-retained store.
Multiple instances by id: StoreRegistry #
When you need N instances of the same type keyed by something runtime — per user, per document, per tab.
// Fetch or create. Returns the existing instance if one already exists for
// this id; otherwise runs the factory.
final cart = StoreRegistry.instance.get<UserCart>(
'user-42',
() => UserCart('user-42'),
);
// Ref-counting is per (type, id):
StoreRegistry.instance.retain<UserCart>('user-42');
StoreRegistry.instance.release<UserCart>('user-42'); // disposed when 0
Lazy singletons #
For stores that should only be created if someone asks for them — not up-front at startup:
StoreManager.instance.registerFactory<ExpensiveStore>(
() => ExpensiveStore.loadFromDisk(),
);
// Construction is deferred until the first get<ExpensiveStore>() call.
final store = get<ExpensiveStore>();
Why not just get_it? #
scoped_store is built on top of get_it and uses it as the underlying
container — you get get_it's speed and ergonomics. 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— retain/release happens ininitState/disposeso you can't forget. - Keyed registry for multi-instance patterns without the
get_itfactory-by-name boilerplate.
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 #
| Type / function | Purpose |
|---|---|
StoreManager.instance |
The singleton registry. Usually accessed via shortcuts. |
retain<T>([factory]) |
Create-or-increment. Returns the instance. |
get<T>() |
Lookup; throws if not registered (or lazy-registers via factory). |
tryGet<T>() |
Same as get but returns null instead of throwing. |
release<T>() |
Decrement; dispose + unregister if count hits 0. |
StoreManager.registerFactory<T>(fn) |
Register a lazy factory without creating yet. |
StoreManager.getRefCount<T>() |
Inspect current ref count (debug / tests). |
StoreManager.reset() |
Clear everything (tests). |
StoreBuilder<T> |
Widget that retains for its subtree, releases on dispose. |
StoreRegistry.instance.get<T>(id, factory) |
Keyed fetch-or-create. |
StoreRegistry.instance.retain<T>(id) / release<T>(id) |
Keyed ref-count. |
Disposable |
Opt-in interface so dispose() gets called on release. |
Gotchas #
retain<T>without a factory throws if the type isn't already registered. Either pass a factory, or callregisterFactory<T>(...)first.StoreBuilderwithcreate:owns the store instance — don't also retain it manually frominitStateof the same widget, or ref counts won't balance.StoreManager.reset()is destructive and intended for tests. In app code you want targetedrelease<T>()calls.- All debug
[StoreManager] ...logs are gated byassert— they compile out of release builds entirely.
License #
MIT