Skip to content

Scoped services ​

Some services hold state that belongs to one part of the screen. Picture a dashboard with two product tables. Each table needs its own sort order and selection, but both read from the same data source.

With Complete Dependency Injector, that's two lines of configuration:

  • ProductRepository uses the default root scope, so the whole app shares one.
  • TableManager uses InjectableScope.instance, and each table is wrapped in its own InjectorScopeWidget, so each table gets its own.

Try it: sort or select rows in one table and the other doesn't change. The # number in each title is that table's TableManager instance.

Scoped services demoOpen full screen ↗

The shared service ​

dart
/// Shared data source. Root scope (the default): one for the whole app.
class ProductRepository {
  static final Factory = InjectableFactory.withoutDependencies<ProductRepository>(
    () => ProductRepository(),
  );
  static final DefaultProvider = Provider(ProductRepository);

  List<Product> all() => const [
        Product('Keyboard', 49),
        Product('Monitor', 199),
        Product('Mouse', 19),
        Product('Webcam', 79),
      ];
}

The scoped service ​

TableManager keeps its state in a stream so the UI can react to it. The important line is scope: InjectableScope.instance.

dart
class TableState {
  const TableState({required this.rows, required this.sortColumn, required this.ascending, this.selected});
  final List<Product> rows;
  final int sortColumn;
  final bool ascending;
  final String? selected;
}

/// Sorting and selection for one table.
class TableManager implements InjectableWithDisposeInterface {
  TableManager(ProductRepository repository)
      : _state = BehaviorSubject.seeded(
          TableState(rows: repository.all(), sortColumn: 0, ascending: true),
        );

  static final Factory = InjectableFactory.withDependencies<TableManager>(
    DependencyDataBuilder.list([ProductRepository]),
    (deps) => TableManager(deps.get<ProductRepository>()),
    // One TableManager per injector that provides it.
    scope: InjectableScope.instance,
  );
  static final DefaultProvider = Provider(TableManager);

  static int _created = 0;
  final int id = ++_created;

  final BehaviorSubject<TableState> _state;
  Stream<TableState> get state => _state.stream;

  void sortBy(int column, bool ascending) {
    final rows = [..._state.value.rows]..sort((a, b) {
        final result = column == 0 ? a.name.compareTo(b.name) : a.price.compareTo(b.price);
        return ascending ? result : -result;
      });
    _state.add(TableState(rows: rows, sortColumn: column, ascending: ascending, selected: _state.value.selected));
  }

  void select(String name) {
    final s = _state.value;
    _state.add(TableState(rows: s.rows, sortColumn: s.sortColumn, ascending: s.ascending, selected: name));
  }

  @override
  Future<void> dispose() => _state.close();
}

Give each table its own scope ​

Each InjectorScopeWidget creates a child injector that provides TableManager. When the table's ViewModel asks for a TableManager, it gets the one from its nearest scope. Asking for ProductRepository walks up to the root.

dart
/// Wrapping each table in its own InjectorScopeWidget is what gives it
/// its own TableManager.
class ScopedTablesDemo extends StatelessWidget {
  const ScopedTablesDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Wrap(
        spacing: 16,
        runSpacing: 16,
        children: [
          for (final title in ['Table A', 'Table B'])
            InjectorScopeWidget(
              providers: [TableManager.DefaultProvider],
              child: ProductTable(title: title),
            ),
        ],
      ),
    );
  }
}

class ProductTable extends StatelessWidget {
  const ProductTable({super.key, required this.title});
  final String title;

  @override
  Widget build(BuildContext context) {
    return DependenciesBuilder<TableViewModel>(
      cleanViewModelInstance: TableViewModel(),
      builder: (context, vm) => dependencyBuilderViewModelSimpleStateHandlingBuildUtilityFn(
        context: context,
        viewModel: vm,
        loadingWidget: const SizedBox(width: 280, height: 200, child: Center(child: CircularProgressIndicator())),
        build: (context, vm) => SizedBox(
          width: 280,
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('$title · TableManager #${vm.manager.id}', style: Theme.of(context).textTheme.titleMedium),
                  Text('Selected: ${vm.table.selected ?? 'none'}', key: Key('$title-selected')),
                  DataTable(
                    key: Key('$title-table'),
                    sortColumnIndex: vm.table.sortColumn,
                    sortAscending: vm.table.ascending,
                    showCheckboxColumn: false,
                    columns: [
                      DataColumn(label: const Text('Product'), onSort: vm.manager.sortBy),
                      DataColumn(label: const Text('Price'), numeric: true, onSort: vm.manager.sortBy),
                    ],
                    rows: [
                      for (final p in vm.table.rows)
                        DataRow(
                          selected: p.name == vm.table.selected,
                          onSelectChanged: (_) => vm.manager.select(p.name),
                          cells: [DataCell(Text(p.name)), DataCell(Text('\$${p.price.toStringAsFixed(0)}'))],
                        ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

The ViewModel looks the same as any other. It doesn't know which scope it's in:

dart
class TableViewModel extends DependencyBuilderViewModelBase {
  TableViewModel()
      : super(dependencyDataList: DependencyDataBuilder.list([TableManager]));

  late TableManager manager;
  late TableState table;

  @override
  void setPropertiesOnDependencyResolution(Dependencies deps) {
    manager = deps<TableManager>();
  }

  @override
  Iterable<Stream> getListOfStreamsToObserve() => [manager.state];

  @override
  void updateViewModelPropertiesOnStreamEmission(List snapshots) {
    table = snapshots[0] as TableState;
  }
}

Register once at the root ​

Factories are global. Register them all once at startup, and provide only the app-wide services at the root:

dart
bool _factoriesRegistered = false;

Injector createRootInjector() {
  final injector = Injector([
    // App-wide services. Scoped services are provided further down the tree.
    CounterService.DefaultProvider,
    ProductRepository.DefaultProvider,
  ]);
  // Factories are global, so register them once per app.
  if (!_factoriesRegistered) {
    injector.registerFactories([
      CounterService.Factory,
      SlowDatabase.Factory,
      ProductRepository.Factory,
      TableManager.Factory,
    ]);
    _factoriesRegistered = true;
  }
  return injector;
}

void main() {
  runApp(InjectorRootWidget(injector: createRootInjector(), child: const DemoApp()));
}

When a table's scope leaves the widget tree, its injector is disposed. That calls dispose() on its TableManager, which closes the stream.

Released under the MIT License.