ioc_widget

ioc_widget

A simple, flexible, and testable dependency injection (DI) solution for Flutter, inspired by the best of both the Provider and get_it packages. This package is designed for widget-level dependency injection, supporting both transient (injectable) and singleton (lazy) strategies, and is ideal for managing dependencies in a scalable, testable, and maintainable way.

Motivation

We already have the Provider package that somehow handles dependency injection and it's perfect.

My only issue with Provider is the easy misuse of it. When you go in the Flutter official docs, they suggest using Provider as a simple state manegement package alongsie the fact that it can work as a dependency injection mechanism.

However in the guide, it's suggested that you have all the list of your providers on the root level of your app, this results in all provided classes to be singletons which is not a realistic case scenario.

So, how about we have something with the same simplicity as Provider but it can provide singletons and new instances everytime you get the dependency just like get_it?

Why use ioc_widget?

Use this package if you want:

Getting Started

Add to your pubspec.yaml:

dependencies:
  ioc_widget: ^<latest_version>

Core Concepts

Usage Example

import 'package:flutter/material.dart';
import 'package:ioc_widget/ioc_widget.dart';

class ClassA {
  String talk() => "I'm Class A! $hashCode";
}

class ClassB {
  final ClassA classA;
  ClassB(this.classA);
  String talk() => "I'm Class B! $hashCode\nAnd I'm Class A! ${classA.hashCode}";
}

class ClassC {
  final ClassB classB;
  ClassC(this.classB);
  String talk() => "I'm Class C! $hashCode\n" + classB.talk();
}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      routes: {
        '/': (_) => const PageA(),
        '/b': (_) => const PageB(),
        '/c': (_) => const PageC(),
      },
      builder: (_, child) => MultiIocWidget(
        dependencies: [
          InjectableWidget<ClassA>(factory: (_) => ClassA()),
          LazySingletonWidget<ClassB>(factory: (ctx) => ClassB(ctx.get())),
          LazySingletonWidget<ClassC>(factory: (ctx) => ClassC(ctx.get())),
          InjectableWidget<CounterNotifier>(factory: (_) => CounterNotifier()),
        ],
        child: child!,
      ),
    );
  }
}

class PageA extends StatelessWidget {
  const PageA({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page A - InjectableWidget')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('ClassA is injected as a transient.'),
            ElevatedButton(
              onPressed: () {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('ClassA: ${context.get<ClassA>().talk()}')),
                );
              },
              child: const Text('Talk (ClassA)'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.pushNamed(context, '/b'),
              child: const Text('Go to Page B'),
            ),
          ],
        ),
      ),
    );
  }
}

class PageB extends StatelessWidget {
  const PageB({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page B - LazySingletonWidget')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('ClassB is injected as a lazy singleton.'),
            ElevatedButton(
              onPressed: () {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('ClassB: ${context.get<ClassB>().talk()}')),
                );
              },
              child: const Text('Talk (ClassB)'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.pushNamed(context, '/c'),
              child: const Text('Go to Page C'),
            ),
            ElevatedButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('Back to Page A'),
            ),
          ],
        ),
      ),
    );
  }
}

class PageC extends StatelessWidget {
  const PageC({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page C - InjectScopedDependency')),
      body: Center(
        child: InjectScopedDependency<ClassA>(
          builder: (ctx) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('ClassC is injected using InjectScopedDependency.'),
                ElevatedButton(
                  onPressed: () {
                    ScaffoldMessenger.of(ctx).showSnackBar(
                      SnackBar(content: Text('ClassC: ${ctx.get<ClassC>().talk()}')),
                    );
                  },
                  child: const Text('Talk (ClassC)'),
                ),
                ElevatedButton(
                  onPressed: () {
                    ScaffoldMessenger.of(ctx).showSnackBar(
                      SnackBar(content: Text('ClassA: ${ctx.get<ClassA>().talk()}')),
                    );
                  },
                  child: const Text('Talk (ClassA)'),
                ),
                ElevatedButton(
                  onPressed: () => Navigator.pop(ctx),
                  child: const Text('Back to Page B'),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

class CounterNotifier extends ChangeNotifier {
  int value = 0;
  void increment() {
    value++;
    notifyListeners();
  }
}

class PageNotifier extends StatelessWidget {
  const PageNotifier({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page Notifier - InjectScopedNotifier')),
      body: Center(
        child: InjectScopedNotifier<CounterNotifier>(
          builder: (ctx, notifier) => Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Counter value: ${notifier.value}', style: const TextStyle(fontSize: 24)),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: notifier.increment,
                child: const Text('Increment'),
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  final notifierFromContext = ctx.get<CounterNotifier>();
                  ScaffoldMessenger.of(ctx).showSnackBar(
                    SnackBar(content: Text('Notifier hashCode: ${notifierFromContext.hashCode}')),
                  );
                },
                child: const Text('Show Notifier HashCode'),
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => Navigator.pop(ctx),
                child: const Text('Back'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class PageNotifierExternal extends StatelessWidget {
  const PageNotifierExternal({super.key});
  @override
  Widget build(BuildContext context) {
    final externalNotifier = CounterNotifier();
    return Scaffold(
      appBar: AppBar(title: const Text('Page Notifier - InjectScopedNotifier (external value)')),
      body: Center(
        child: InjectScopedNotifier<CounterNotifier>(
          value: externalNotifier,
          builder: (ctx, notifier) => Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Counter value: ${notifier.value}', style: const TextStyle(fontSize: 24)),
              ElevatedButton(
                onPressed: notifier.increment,
                child: const Text('Increment'),
              ),
              ElevatedButton(
                onPressed: () => Navigator.pop(ctx),
                child: const Text('Back'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

API Reference

InjectableWidget

Provides a new instance of T every time it is requested from the context.

LazySingletonWidget

Provides a single instance of T for the subtree, created on first use.

MultiIocWidget

Registers multiple dependencies at once. Useful for grouping related dependencies.

InjectScopedDependency

Widget that exposes a dependency in its context widget tree as a singleton and gets disposed when its parent is disposed. (Renamed from IocConsumer in v2.0.0)

InjectScopedNotifier

Widget that injects a ChangeNotifier from the IoC container, rebuilds when the notifier updates, and disposes it automatically when removed from the tree. The notifier instance is a singleton within the widget scope and will be recreated if the widget is disposed and rebuilt.

context.get()

Extension on BuildContext to retrieve a dependency of type T from the nearest provider.

Testing

The package is designed for testability. You can easily override dependencies in your widget tests. Example:

testWidgets('InjectableWidget creates a new instance every time', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: InjectableWidget<TestClass>(
        factory: (_) => TestClass(),
        child: Builder(
          builder: (context) {
            final a = context.get<TestClass>();
            final b = context.get<TestClass>();
            return Text('${a.hashCode}-${b.hashCode}');
          },
        ),
      ),
    ),
  );
  expect(TestClass.instanceCount, 2);
});

testWidgets('InjectScopedDependency exposes a new instance in its scope', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: InjectableWidget<TestClass>(
        factory: (_) => TestClass(),
        child: Builder(
          builder: (ctx) {
            final _ = ctx.get<TestClass>();
            return InjectScopedDependency<TestClass>(
              builder: (ctx) {
                final a = ctx.get<TestClass>();
                final b = ctx.get<TestClass>();
                final c = ctx.get<TestClass>();
                return Text('${a.hashCode}-${b.hashCode}-${c.hashCode}');
              },
            );
          }
        ),
      ),
    ),
  );
  // Should create two instances (one for each get)
  expect(TestClass.instanceCount, 2);
});

Lifecycle & Disposal

When to use

When NOT to use

License

MIT

EazyRouter Resume Contact me