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

Libraries

scoped_store
Minimal scoped dependency injection for Flutter.