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.
“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
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.
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.
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.
Thanks for the feedback! I updated the post to reference riverpod’s code snippets which look like they would help a lot.
What about BLoC, GetX, Redux and Flutter Hooks?
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.
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?
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.
Documentation says “DON’T call read inside the body of a provider”
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.
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