scoped_store 0.2.2 copy "scoped_store: ^0.2.2" to clipboard
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_it treats every registration as exclusive — you're responsible for ordering retain/release correctly.
  • Widget-scoped lifecycle via StoreBuilder / OwnedStoreView / StoreStateMixin — retain/release happens in initState / dispose so you can't forget.
  • Keyed instances (*ById) without get_it's factory-by-name boilerplate.
  • Own instance cache that's not confused by @injectable factory registrations — get_it factories return fresh instances on every get<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 if T isn't already registered somewhere (scoped_store cache, lazy factory, or get_it container). Either pass a factory, or call registerFactory<T>(...) first.
  • Don't mix lifecycle owners for the same T. If a StoreBuilder / OwnedStoreView / StoreStateMixin already owns the store, child widgets should use StoreView<T> or plain get<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 from setUp in tests, never from app code.
  • Debug [StoreManager#N] … logs are gated by assert — they compile out of release builds entirely. A totalInstancesCreated > 1 warning in debug usually means hot restart or someone bypassing the singleton.

License #

MIT

1
likes
130
points
22
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)

License

MIT (license)

Dependencies

flutter, get_it

More

Packages that depend on scoped_store