In this article, I'd like to talk about how I ended up using Riverpod as a state management system for my flutter app.
- Provider with MVVM
-
get_it
for global dependencies - Riverpod
Provider with MVVM
When I started the flutter project as a newbie two years ago, I wanted to build something similar to the MVVM pattern I used to use for Android application development. After some research, I found Provider the most straightforward option. (I wasn't even thinking of state management at that time)
So, I was doing something like this at the entry point of the app:
final serviceA = ServiceA();
final serviceB = ServiceB();
MultiProvider(
providers: [
Provider<RepoA>(create: (_) => RepoA(service:serviceA)),
Provider<RepoB>(create: (_) => RepoB(service:serviceA)),
Provider<RepoC>(create: (_) => RepoC(service:serviceB)),
],
child: MaterialApp(),
)
I've been initializing the services
and repositories
at the application launch time and adding them into the Provider using the MultiProvider
. It allowed the ViewModels
of each page to access the repositories. (Yes, one view
, one ViewModel
)
class ViewModelA with ChangeNotifier {
ViewModelA(this.repo) {
init();
}
final RepoA repo;
String title;
Future<void> init() async {
title = "test";
notifiyListeners();
}
}
// Wrap the page with ChangeNotifierProvider
class PageA extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => new ViewModelA(Provider.of<RepoA>(context, listen: false)),
child: PageView(),
);
}
I could access the' ViewModel' in any widgets used in the PageView
using the Selector
.
Selector<ViewModelA, String>(
selector: (_, vm) => vm.title,
builder: (_, data, __) {
return Text('title: ${data}');
}
)
It was annoying to write the boilerplate codes of the Selector
every time I needed to use variables in the ViewModel, but I helped to reduce unnecessary rendering. So far, It's not bad.
After growing the code bases with many repositories and services, I realized that every time I add more repositories in the global provider scope with the MultiProvider
, it creates the cascading tree in the widget hierarchy. It made debugging super tricky with the Widget Inspector tool.
One day, I decided to remove MultiProvider
and convert all global dependencies as singletons. Technically they were already singletons but just living in the Provider scope anyway.
get_it for global dependencies
After some research, I found the get_it looked promising.
I replaced MultiProvider
with getIt singletons at the application start:
final serviceA = ServiceA();
final serviceB = ServiceB();
getIt.registerSingleton<RepoA>(RepoA(service:serviceA));
getIt.registerSingleton<RepoB>(RepoB(service:serviceA));
getIt.registerSingleton<RepoC>(RepoC(service:serviceB));
And the View and ViewModel like this:
class ViewModelA with ChangeNotifier {
// This allows passing mocked repository to the ViewModel for testing purposes
ViewModelA(RepoA? repo): _repo = repo ?? GetIt.get<RepoA>() {
init();
}
final RepoA _repo;
String title;
Future<void> init() async {
title = "test";
notifiyListeners();
}
}
class PageA extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => new ViewModelA(),
child: PageView(),
);
}
Now, I don't see the cascading tree in the widget inspector, and much more comfortable debugging the widgets. I also liked it because the widget doesn't need to know what dependencies the ViewModel needs. Thanks to the GetIt can give it to me without context
. But I still have a slow application start problem.
A few months later, I wanted to tackle the start-up time issue and realized I might have a few options.
- Use lazy singleton with GetIt.
- Go for another approach.
I could have converted the global dependencies to lazy singleton with GetIt so that it doesn't need to initialize all dependencies at the application start. However, It would still keep in the memory until the app completely shut down. I wanted something that could automatically initiate and de-initiate based on the need.
So another round of research started!
Riverpod
In the meantime, I also was working on a react project that uses the React Query, and even though it's not a client-side state management library, I found it a simple and nice way of managing states. I wanted to have something similar but for flutter. Finally, the Riverpod caught my eye. The developer of the Provider developed Riverpod as a successor of the Provider. (Later, I found Recoil is the one I've been looking for in the React app for client state management, and it has a lot of similarities with Riverpod)
As a first step, I converted all singleton services and repositories into Riverpod providers. I also defined abstract classes
for better testability.
abstract class ServiceA {}
class ServiceAImpl implements ServiceA {}
abstract class ServiceB {}
class ServiceBImpl implements ServiceB {}
abstract class RepoA {
Future<List<Entity>> getMany();
}
class RepoAImpl implements RepoA {
RepoAImpl(this.service)
final ServiceA service;
@override
Future<List<Entity>> getMany() {
return service.getMany();
};
}
abstract class RepoB {}
class RepoBImpl implements RepoB {
RepoBImpl(this.service)
final ServiceA service;
}
abstract class RepoC {}
class RepoCImpl implements RepoC {
RepoCImpl(this.service)
final ServiceB service;
}
final serviceAProvider = Provider<ServiceA>((_) => ServiceAImpl());
final serviceBProvider = Provider<ServiceB>((_) => ServiceBImpl());
final repoAProvider = Provider<RepoA>((ref) => RepoAImpl(ref.watch(serviceAProvider)));
final repoBProvider = Provider<RepoB>((ref) => RepoBImpl(ref.watch(serviceAProvider)));
final repoCProvider = Provider<RepoC>((ref) => RepoCImpl(ref.watch(serviceBProvider)));
I also started getting rid of the ViewModels. Since I can now define a state as a provider that is accessible from any widgets, the ViewModel was too heavy as a state. This way, I was able to write more reusable codes.
Create state providers
// Simple future state provider
final stateAProvider = FutureProvider<List<EntityA>>((ref) {
return ref.watch(repoAProvider).getMany();
});
// State provider with notifier that contains methods.
final stateBProvider = StateNotifierProvider<EntityB>((ref) {
return StateBNotifier(EntityB(), ref.watch(repoAProvider));
});
class StateBNotifier extends StateNotifier<ThemeMode> {
StateBNotifier(super.state, this.repo);
final RepoA repo;
void fetch() {
state = repo.getMany();
}
}
I can access the providers from widgets.
class WidgetA extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final stateB = ref.watch(stateBProvider);
return InkWell(
onTap: () => ref.read(stateBProvider.notifier).fetch(),
child: Text('${stateB.name}'),
);
}
With the Riverpod, I could make the state lighter and more reusable. I often create a container widget that holds the state that wraps the pure widgets. This way, the design widget is reusable as a design widget without business context. Providers and widgets are all unit-testable by simply mocking the repository or other dependency of the Provider.
Top comments (2)
Very insightful!!!! Helped me a lot
Thanks! Glad to hear that! ;)