Flutter: Comparing GetIt, Provider and Riverpod

Today we’re going to look at 3 of the more popular libraries for basic state management in Flutter: GetIt, Provider and riverpod.

For each of the libraries, we’ll look at how you can perform data-binding, data-injection, and how you might mock them for testing. To follow along with the examples below, check out the code on github.

The setup…

There are many types of data to share in Flutter: Futures, Streams, ValueNotifiers etc. In this example, we’ll go with a classic ChangeNotifier based manager with 2 mutable properties. It will look something like:

class AppManager extends ChangeNotifier {
  ...
  int _count1 = 0;
  int get count1 => _count1;
  set count1(int value) {
    _count1 = value;
    notifyListeners();
  }

  int _count2 = 0;
  int get count2 => _count2;
  set count2(int value) {
    _count2 = value;
    notifyListeners();
  }
}

In this case we’re using 2 simple integers, but you could imagine you have any number of fields on this model, and could be any type of data or lists of data.

Of course you could have any number of controllers, managers or services as well. For the sake of this example, lets pretend we have two other objects to pass around: SettingsManager and FileService, and that both AppManager and SettingsManager require the FileService. This will allow us to demonstrate the core requirements of any state management solution.

Provider

Provider passes data around using parent:child relationships within the widget tree. A MultiProvider widget can be used to pass multiple objects to the widget tree below:

class ProviderExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider(create: (_) => FileService()),
        ChangeNotifierProvider(create: (context) {
          return SettingsManager(() => context.read<FileService>());
        }),
        ChangeNotifierProvider(create: (context) {
          return AppManager(() => context.read<FileService>());
        }),
      ],
      child: ...,
    );
  }
}

From within the view layer a context.read extension can be used to access data, and a context.watch or context.select can be used to bind to it:

class View1 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    void handleTap() => context.read<AppManager>().count1++;
    int count = context.select((AppManager m) => m.count1);
    ...
  }
}

The select call here is binding our widget to the ChangeNotifier, it will now rebuild whenever the .count1 property is changed, while ignoring changes to count2.

Testing with Provider

To test with Provider, you can extend your data objects to create a mock, and provide that above a widget you would like to test:

class MockFileService extends FileService { ... }

return MultiProvider(
  providers: [
    Provider(create: (_) => MockFileService()),
    ...
  ],
  child: ...,
);

Unfortunately Provider does not have any system to allow you to override dependencies from “outside” which can make things a bit harder to test.

Limitations of Provider

As mentioned, `Provider` is limited in it’s testability because it has no built-in override system. It also can be hard to work with outside of the widget tree, due to the required context for every lookup. Finally, it has issues when trying to provide multiple objects of the same type.

GetIt + GetItMixin

GetIt is an implementation of the classic Service Locator pattern, providing various static methods to register singletons or factories:

final sl = GetIt.instance;
sl.registerSingleton(FileService());
sl.registerSingleton(SettingsManager(() => sl.get<FileService>()));
sl.registerSingleton(AppManager(() => sl.get<FileService>()));

A get method can be used to read data, and the GetItMixin package provides various ways to bind to it within your views. In this case, to bind to a property on a ChangeNotifier we can use watchOnly which we get from the mixin:

class View1 extends StatelessWidget with GetItMixin {
  @override
  Widget build(BuildContext context) {
    void handleTap() => GetIt.I.get<AppManager>().count1++;
    int count = watchOnly((AppManager m) => m.count1);
    ...
  }
}

Testing with GetIt

To test with GetIt, you can use the unregister and register methods to provide a mock class at any time, from anywhere, which makes testing a breeze:

GetIt.I.unregister(GetIt.I.get<FileService>());
GetIt.I.registerSingleton<FileService>(MockFileService());

You can also use the pushNewScope and popScope to easily setup and teardown a set of registered objects:

GetIt.I.pushNewScope();
GetIt.I.register(MockFileService());
// do tests
GetIt.I.popScope();

Limitations of GetIt

GetIt also suffers from the issue of providing instances of the same type, however it does expose a name field when registering, which offers a reasonable workaround for the issue. Another potential weakness is that you may have to manually dispose or unregister items as they are not intrinsically part of the widget tree. Lastly, some developers may not like the global nature of GetIt singletons, preferring the more restrictive scoping models of riverpod or Provider, while other developers may view this as a benefit.

Riverpod

With riverpod, you define globally accessible “providers”, and then you use a ref object to read them. A ProviderScope must also be placed around the widget tree:

final fileServiceProvider = Provider((_) => FileService());
final settingsManagerProvider = ChangeNotifierProvider((ref) {
  return SettingsManager(() => ref.read(fileServiceProvider));
});
final appManagerProvider = ChangeNotifierProvider((ref) {
  return AppManager(() => ref.read(fileServiceProvider));
});

class RiverpodExample extends StatelessWidget {
  const RiverpodExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: ...,
    );
  }
}

With your providers declared, you can subclass the ConsumerWidget and declare a slightly modified build method, to get a ref which will then allow you to read the providers:

class View1 extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    void handleTap() => ref.read(appManagerProvider).count1++;
    int count = ref.watch(appManagerProvider.select((p) => p.count1));
    ...
  }
}

Testing with riverpod

riverpod provides a simple override mechanism that can be used to force a mock into any provider:

ProviderScope  overrides: [
    fileServiceProvider.overrideWithValue(MockFileService())
  ],
  child: ...,
);

Limitations of riverpod

Feature wise there are not many limitations with riverpod, it’s highly testable and allows for easy injection of objects with the same type. It’s biggest drawback may be the slightly awkward usage. It requires a custom widget, a modified build method signature, and all reads require a ref. While this feels a little annoying at first, it is not an issue once you get used to it. To ease this further riverpod also comes with a set of code snippets for Android Studio and VSCode.

Closing thoughts…

Really you can’t go wrong with any of these state-management libraries. Which you choose is mainly a matter of personal taste. GetIt comes with the most keep-it-simple approach and provides a hard de-coupling of logical objects and the widget tree. riverpod introduces a powerful twist on the service locator formula with its ‘many-locators’ + scoping approach, but is not quite as easy to learn as the others. Last but not least (well ok, it’s basically least) Provider gives you the no frills, traditional “inherited widget” style approach with a very simple API that works great inside of a widget tree, but not so great outside of it.

If you have any questions (or corrections!), please let us know below.


Need help building something cool in Flutter? We’d love to chat.

shawn.blais

Shawn has worked as programmer and product designer for over 20 years, shipping several games on Steam and Playstation and dozens of apps on mobile. He has extensive programming experience with Dart, C#, ActionScript, SQL, PHP and Javascript and a deep proficiency with motion graphics and UX design. Shawn is currently the Technical Director for gskinner.

@tree_fortress

12 Comments

  1. “It’s biggest drawback may be the somewhat awkward usage. It requires a custom widget, a modified build method signature, and all reads require a ref.”

    The first part of this is factually inaccurate. Like Provider, Riverpod provides a “Consumer” widget that can be used from normal Stateless/Stateful widgets.

    https://pub.dev/documentation//flutter_riverpod/latest/flutter_riverpod/Consumer-class.html

  2. Yes true, all of the solutions have builders you can use you rebuild portions of a widget, at the cost of extra indentation and line count. But when it comes to rebuilding the entire widget I find `riverpod` slightly more awkward to use. YMMV!

    I think it’s still factually correct. Riverpod requires using either `ConsumerWidget` or `Consumer`, with `GetIt` and `Provider` this is optional, you can instead use `context.watch`, or `watchOnly`. It’s true GetIt does require a mixin, but does not effect the `build()` signature, and does not force inheritance, so it feels a little less invasive to me.

  3. Ok, I see. If you are saying that “custom widget” means Consumer or using a ConsumerWidget then I’d agree with your points. I was under the impression that custom widget meant you are required to use ConsumerWidget.

  4. However, you are only forced into using inheritance if you use ConsumerWidget. If you use Consumer then you are composing widgets just as you normally would. Your point about the extra indentation and line count is still valid though.

  5. Thanks for the feedback! I updated the post to reference riverpod’s code snippets which look like they would help a lot.

  6. What about BLoC, GetX, Redux and Flutter Hooks?

  7. The first 3 are robust frameworks that are much more complex than simple state management tools. `Hooks` is not really state-management either, it’s more about state reuse. hooks + riverpod is a popular setup, for example. GetIt also provides a package to use alongside hooks.

  8. Is it your matter of taste for using functions rather than using variable,
    “`
    void handleTap() => ref.read(appManagerProvider).count1++;
    “`
    it could also be
    “`
    final handleTap = ref.read(appManagerProvider).count1++;
    “`
    If it is beyond your matter of taste than I would like to know magic behind this?

  9. That wouldn’t work here, `handleTap` is a method that is bound to a button, and it increments the count. Showing how UI actions can drive changes to the model.

    If you wrote `final handleTap = ref.read(appManagerProvider).count1++;` inside of build, you would increment the count each time build is called, and you would not be able to pass it in as a tap handler.

  10. Documentation says “DON’T call read inside the body of a provider”

  11. This is true, but the way we are using it here is safe. We are passing a closure which calls `read` which is similar to passing `read` itself, which is the recommended approach in the docs.

    We do it this way here as to not pollute the class with a dependency on any one state management solution, allowing us to share the same logic classes across all the examples.

  12. In other words, this could (potentially) be bad:
    `return AppManager(ref.read(fileServiceProvider));`

    But this is totally safe:
    `return AppManager(() => ref.read(fileServiceProvider));`

    In the first case, if fileServiceProvider ever changes, the AppManager will have a stale instance. In the latter, the AppManager can always request the latest fileServiceProvider by invoking it’s closure, which internally calls read each time.

    You can see some discussion of this here: https://github.com/rrousselGit/river_pod/issues/864#issuecomment-955194211

Leave a Reply

Your email address will not be published. Required fields are marked *