Flutter: Adding (scalable) state management to the new `skeleton` template

Recently we took a deep dive into the new skeleton template included in the Flutter SDK. As noted in the article, one of the big missing pieces in the template is a scalable state management solution with dependency injection and data-binding.

Given that, we thought it would be informative to convert it to use a couple of popular state management libraries, specifically riverpod and GetItMixin.

Converting to riverpod

For some background, the skeleton template uses a ChangeNotifier based SettingsController and it passes that controller down through the tree to various widgets. To simulate that with riverpod, we’ll create 2 providers inside of main.dart. We also need to add a ProviderScope to the top of the tree:

void main() async {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}

final settingsProvider = ChangeNotifierProvider((ref) => SettingsController(ref));
final settingsServiceProvider = Provider((ref) => SettingsService());

In the original template, SettingsController requires a SettingsService dependency be passed in via the constructor. With riverpod, the expected way to do something like this, is to pass a ref into the controller, and have the controller lookup it’s dependency using the ref.read method.

This looks something like:

class SettingsController with ChangeNotifier {
  SettingsController(this.ref);
  final Ref ref;
  SettingsService get _settingsService => ref.read(settingsServiceProvider);
 ...
}

The benefits of having SettingsManager look up the settings object rather than instantiating it itself, is that it becomes very easy for us to mock the SettingsService later if we want. This is typically known as the inversion of control principle.

View Binding

With the changes to the controller, we’re now ready to update the views. In the skeleton app there are two pages that access this SettingsController.themeData, they are MyApp and SettingsView.

MyApp.dart
In the original template, an AnimatedBuilder is used to rebuild the entire app anytime settings controller calls notifyListeners. With riverpod we can remove the AnimatedBuilder, and extend ConsumerWidget instead. ConsumerWidget has a 2nd parameter in it’s build method which is a ref we can use to watch one of our providers:

class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeMode = ref.watch(settingsProvider.select((s) => s.themeMode));
    ...

Notice that the provider.select method here which allows us to bind only to the themeMode value instead of the entire ChangeNotifier.

SettingsView.dart
Like the MainApp, this view binds to themeMode. It also modifies it by calling updateThemeMode. To accomplish this, we switch to a ConsumerWidget, and use the watch and read methods respectively:

class SettingsView extends ConsumerWidget {
 ...
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeMode = ref.watch(settingsProvider.select((s) => s.themeMode));
    return Scaffold(
      body: Padding(
        child: DropdownButton<ThemeMode>(
          value: themeMode,
          onChanged: ref.read(settingsProvider).updateThemeMode,
          ...

Finishing Up

The final step to converting the template, is to load the settings before showing the app. The original template did this in main.dart, before calling runApp. Like this:

void main() async {
  final settingsController = SettingsController(SettingsService());
  await settingsController.loadSettings();
  runApp(MyApp(settingsController: settingsController));
}

This is a bit tricky to do with riverpod, as it requires you be inside of a Widget to obtain a ref. To do this, we converted MyApp to be a StatefulWidget, then used initState and a _hasSettingsLoaded flag to load the settings, before we show the actual app:

class _MyAppState extends ConsumerState<MyApp> {
  bool _hasSettingsLoaded = false;

  @override
  void initState() {
    ref.read(settingsProvider).loadSettings().then((_) {
      setState(() => _hasSettingsLoaded = true);
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    if (_hasSettingsLoaded == false) return Container();
    final themeMode = ref.watch(settings.select((s) => s.themeMode));
    return MaterialApp(...);
   }
}

That’s it! The app is identical to before, but the property drilling is gone, and you can easily mock the settings service, or the controller if you need.

If you’d like, check out the full example on github.com.

Converting to GetItMixin

Another great DI + binding setup is GetIt and GetItMixin. If you’re not familiar with these packages, check out our post here for a quick dive into how they work.

Converting to GetIt is even easier than using riverpod. To get started, we need to register our singletons, which we’ll do in main.dart. Additionally, because GetIt has no requirement for a WidgetRef we can restore the original logic of loading settings inside of main, and we can revert MyApp to be stateless:

void main() async {
  registerSingletons();
  await settings.loadSettings();
  runApp(MyApp());
}

void registerSingletons() {
  GetIt.I.registerLazySingleton(() => SettingsController());
  GetIt.I.registerLazySingleton(() => SettingsService());
}
// Add some shortcuts methods so views can easily fetch controller
SettingsController get settings => GetIt.I.get<SettingsController>();

You may notice that our SettingsController no longer takes anything in it’s constructor. This is because GetIt does not require a ref when looking things up. Thus, SettingsController can be simplified to:

class SettingsController with ChangeNotifier {
  SettingsService get _settingsService => GetIt.I.get<SettingsService>();
  ...
}

Similar to the previous example with riverpod this service is easily swapped out for a different implementation later, making it very simple to mock.

Another way we could handle this, is to pass the service in via constructor as before, but use GetIt to look it up:

GetIt.I.registerLazySingleton(() => SettingsController(GetIt.I.get<SettingsService>()));

In some ways this is preferable, as it more clearly shows the dependencies that SettingsController has. But it’s a little harder to read and slightly more convoluted. In the name of simplicity we will keep it similar to the riverpod approach, and go with the previous example, where the controller request the service itself.

View Binding

The final thing we need to do, is update the ui views to bind to the themeMode. Again the two views we need to change are MyApp.dart and SettingsView.dart

MyApp.dart
We can bind to the themeMode value using a GetItMixin and the watchOnly method:

class MyApp extends StatelessWidget with GetItMixin {
  @override
  Widget build(BuildContext context) {
    final themeMode = watchOnly((SettingsController s) => s.themeMode);
    ...

In the above, watchOnly() allows us to bind only to the single property, and not to the entire ChangeNotifier. This is similar to provider.select().

SettingsView.dart

class SettingsView extends StatelessWidget with GetItMixin {
  @override
  Widget build(BuildContext context) {
    final themeMode = watchOnly((SettingsController s) => s.themeMode);
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: DropdownButton<ThemeMode>(
          value: themeMode,
          onChanged: settings.updateThemeMode,
          ...

Again we use GetItMixin and the watchOnly method to bind to data. To change the value we can just use the settings shortcut we defined earlier, and use the updateThemeMode method.

We’re done! Again the app is identical in functionality, but the services and managers are now easily mocked, and there is no more need for property drilling. Check out the full example on github.com.

As always, please let us know if you have any questions or comments 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

6 Comments

  1. Nice article! I think this demonstrates really nicely how easy it is to add your favorite Dependency Injection / View Binding package to the skeleton template, and one reason we didn’t want to “pick a winner.”

    The only part where I disagree with your approach: In my view, the `SettingsController` should continue to take in a `SettingsService` as part of the constructor. First, that still allows for proper inversion of control — you can pass in any instance of `SettingsService` that you’d like.

    Second, constructor parameters are even easier to work with in tests — just pass a `MockSettingsService` as part of the constructor. You don’t need to register any mocks with Riverpod or get_it, or remember to reset any mocks between tests. This means that each test can be properly isolated from each other without sharing any state. And sharing state between tests has always ended poorly for me, haha 🙂

    What do you think?

  2. Ha, ya I have argued with Remi about a similar thing: https://github.com/rrousselGit/river_pod/issues/864

    Basically, when it comes to `riverpod`, as it is really geared towards using immutable data, it’s considered good practice to look up the dependency each time you access it, because it may have changed. I don’t entirely agree it always matters, but it is heavily recommended in the docs so I went with it for this example.

    When it comes to `GetIt`, I think both ways have subtle pros and cons. It’s bit of a 50/50 thing for me… Of course you can directly supply a mock to the controller, but being able to override is important too. You could override your SettingsService indirectly with a mock, by doing something like:

    registerSingletons();
    GetIt.I.pushNewScope();
    GetIt.I.registerLazySingleton<SettingsController>(() => SettingsController(SettingsServiceMock()));
    await tester.pumpWidget(const MyApp());

    The benefit here is `SettingsController` more clearly communicates what it depends on. The potential downside I see is the lack of directness. I’m creating 2 controllers, when what I really want to do is swap out a service. It shouldn’t create any weird side effect, especially since these are lazy mappings, but you never know? I could see this getting messy if I had many different services, or shared services across managers?

    My cautious side says just keep it simple, live with the fact that the code is a little less self-documenting, for the trade-off of eliminating any future headaches or bugs.

    So if we want to mock a service, we can simply mock the service:

    registerSingletons();
    GetIt.I.pushNewScope();
    GetIt.I.registerLazySingleton<SettingsService>(() => SettingsServiceMock());
    await tester.pumpWidget(const MyApp());

    There is a best of both worlds solution imo. With riverpod, you just pass in a closure, that returns the dependency. This lets you mock/override services directly, while still exposing the dependencies in the constructor, something like:

    final settingsProvider = ChangeNotifierProvider(
    (ref) => SettingsController(() -> ref.read(settingsServiceProvider));
    );

    or, I guess with `GetIt`, we could just pass it directly:

    GetIt.I.registerLazySingleton(() =>
    SettingsController(GetIt.I.get<SettingsService>()));

  3. I tend to

    I tend to care very little about how easy it is to “mock” something. It should be doable, of course, but it’s not something I care to have my app optimized for.
    Managing the actual dependencies between the actual code should be simple.

    > This means that each test can be properly isolated from each other without sharing any state. And sharing state between tests has always ended poorly for me, haha

    I don’t write much test for Flutter (yet), but I do dozens a week at work in another language.
    We have thousands of tests with thousands of mocked services.
    It’s not flutter/dart so there’s none of this riverpod stuff-stored-in-global-variables-but-accessed-through-context-which-is-actually-just-the-element-in-the-tree-that-the-widget-created nonsense, but there is a dependency container and dependency injection through constructor autowire (or manual wire).

    Mocking classes is super easy and isolating test is also super easy.

    Our unit tests are littered with mocked services – we have thousands of tests with thousands of mocked services in them, and I can remove about 70-80% of our actual code and all unit tests would pass just fine.

    At this point we are testing the mocking library and not much else. But at least the builds are green, and we can deploy.

    When the tooling allows for mocking any class with a single line of code, developers will tend to favor that solution when writing tests, rather than properly separating code with different concerns.

  4. I’ve been finding mocking quite useful lately, as I do a lot of responsive UI testing on desktop, and it’s very nice to be able to quickly mock certain native services like Location, Payment or BlueTooth, so that the entire app continues to work and the UI can be built in desktop where it’s extremely fast to test responsive designs and behaviors.

    Regarding containers, refs, contexts etc. I do find `GetIt`s method of pushing and popping scope, a more intuitive way to handle scoping and overrides, than `riverpods` use of a Widget based scoping system. But both work fine, and it’s not a major inconvenience.

  5. I mean, it’s pretty important that your state-management solution provide a simple and robust way to swap out a service or controller with a mock.

    I don’t actually use this primarily for unit testing, I use it often to allow me to build 99% of the app on desktop, mocking device-specific services like payments etc, but building the UI extremely quickly.

    So for example, it’s really great with GetItMixin, I can do something like this when starting the app:

    if(isOnDesktop){
    GetIt.I.pushScope();
    GetIt.I.registerSingleton(MockPurchaseService());
    }

    Now no line in my entire app needs to change, no one is aware that a mock purchase service is being used, and I can test my entire IAP flow on desktop, easily and quickly dialing in the responsiveness.

    Of course this is also super useful for running integration tests, where you want to run through the core views/features of your app, but isolate any service dependencies.

  6. Hi Author, thanks for bring us this interesting article .

    At the riverpod section, I want to know is there any elengent way to skip the `_hasSettingsLoaded` in `_MyAppState`?

    Instead, can I use a `FutureProvider` to solve the loading state of settingsController?

    Thanks,

Leave a Reply

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