DEV Community

Jhin Lee
Jhin Lee

Posted on

Journey to the riverpod for my flutter app.

In this article, I'd like to talk about how I ended up using Riverpod as a state management system for my flutter app.

  1. Provider with MVVM
  2. get_it for global dependencies
  3. 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(),
)
Enter fullscreen mode Exit fullscreen mode

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(),
  );
}
Enter fullscreen mode Exit fullscreen mode

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}');
  }
)
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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(),
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Use lazy singleton with GetIt.
  2. 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)));
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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}'),
    );
}
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (2)

Collapse
 
dvaefurn profile image
Davefurn

Very insightful!!!! Helped me a lot

Collapse
 
leehack profile image
Jhin Lee

Thanks! Glad to hear that! ;)