State Management With Provider

The Flutter team recommends several state management packages and libraries. Provider is one of the simplest to update your UI when the app state changes. By Michael Katz.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Keeping a List of Favorites

The app now shows the currency list, you can tap one of the rows to interact it. You can select it as a favorite or update the amount in your wallet.

The buttons are already wired up as part of the starter project, but the UI doesn't yet update to reflect the new state. When the user taps a button, they would expect the screen to show the updated state immediately. You can force an update by leaving a screen and then going back. But wouldn't it be nice though if the widgets updated right away?

The currency detail screen

Reusing the Provider Pattern

The CurrencyDetail screen also has a view model that holds its UI state. You can apply Provider here again in the same pattern to have the views update.

Open lib/ui/views/currency_detail.dart. At the top of the file, add the import at the // TODO: add import:

import 'package:provider/provider.dart';

This makes the package available for use.

Next, replace buildBody() with:

Widget buildBody(BuildContext context) {
  return ChangeNotifierProvider<CurrencyDetailViewModel>(
      create: (_) => CurrencyDetailViewModel(
        currency: currency,
        exchange: exchange,
        favorites: favorites,
        wallet: wallet,
      ),
    child: Consumer<CurrencyDetailViewModel>(
      builder: (context, viewModel, _) =>
          assembleElements(context, viewModel),
    ),
  );
}

This is exactly the same procedure you applied to CurrencyList. It block wraps the CurrencyDetailViewModel creation in a ChangeNotifierProvider, and then provides it to the rest of the widget tree through a Consumer.

Now you have to update CurrencyDetailViewModel to be a ChangeNotifier and have it send notifications when there are updates. To do that, open lib\ui\view_models\currency_detail_viewmodel.dart.

Update the class definition by replacing the line after // TODO: update class definition with:

class CurrencyDetailViewModel with ChangeNotifier {

Then, at the end of both toggleFavorites() and commitToWallet(), find the // TODO: add notifyListeners and replace it with:

notifyListeners();

This tells any listeners, such as the provider, that there are state changes.

Build and run the app again and verify that tapping the buttons update the views right away. Red hearts are for favorite currencies, and green hearts are for favorite currencies that are also in the wallet.

Showing a green heart with wallet and favorite wired up

Choosing a Stateful Widget

At this point it might feel like you've introduced a lot of machinery just to refresh a list and change an icon color. Flutter has StatefulWidget that allows you to create a custom widget that manages its own state. You make one with a custom state object and update it through the setState method.

In Moola X, if CurrencyDetail were to use StatefulWidget instead of Provider, it could track favorite status as an internal Boolean value and wallet amount as a float.

Conceptually using StatefulWidget makes sense when state changes just affects the widget and its children, like visual changes or navigation information. In Moola X, the state changes are not only reflected on the current screen, but update app-level data models. Therefore, the changes are expected to persist across many screens. Since the state management is broader than one widget, using Provider allows for a more flexible architecture.

Providing Favorites Across Multiple Screens

Since favorites are expected to persist across the app, users expect to see that state is reflected on the currency list when they tap the back button. To make that work, you could go down the same path as CurrencyListViewModel: each view model can listen for changes and explicitly forward those notifications to their own consumers. But, this pattern is brittle and cumbersome.

It's more straightforward to create a Provider for the Favorites model itself at the top of the app and make it available everywhere it's needed. Go up to the top by opening lib/main.dart.

At the top of the file, replace // TODO: add import here with:

import 'package:provider/provider.dart';

This makes the Provider package available.

Next, replace buildBody() with:

Widget buildBody() {
  final exchange = Exchange(service: CurrencyServiceLocal())..load();
  final storage = StorageServiceLocal();
  return ChangeNotifierProvider<Favorites>(
      create: (_) => Favorites(storage: storage),
    child: Consumer<Favorites>(
      builder: (_, favorites, __) => buildTabBar(
          exchange,
          favorites,
          Wallet(exchange: exchange, storage: storage)
      ),
    ),
  );
}

This repeats the ChangeNotifierProvider/Consumer same pattern as before. Here, Favorites is the object provided. The buildTabBar method reuses the Consumer's favorites when constructing both CurrencyList and FavoriteTable so it's the same object and will update both tabs when it's notified.

Speaking of updating listeners, you need to fix Favorites to get the project to compile. Open lib/services/user/favorites.dart. At the top of the file replace // TODO: add import here with this import:

import 'package:flutter/foundation.dart';

Then, change the class definition with this mixin under // TODO: change class definition:

class Favorites with ChangeNotifier {

Next, add a call to notifyListeners() to the completion block in the constructor, by replacing the entire Favorites() with:

Favorites({required this.storage}) {
  storage.getFavoriteCurrencies().then((value) {
    _favorites.addAll(value);
    notifyListeners();
  });
}

Finally, at the end of both toggleFavorite() and reorder(), find // TODO: add notifyListeners and replace it with:

notifyListeners(); 

These calls make sure that when the state of the favorites updates through loading, setting or changing the list order, any of the listening providers will update the consumers. Thus the UI will update.

Re-run the app and now the currency list's hearts will stay in sync with changes on the detail screen. :]

Favorites showing up now on currency list

As a bonus, the table on the favorites tab also now stays in sync with changes to the favorites model. Updates to the favorites state or use of the re-ordering controls now work. This happened because the FavoritesTable is also a child of the Consumer of the favorites Provider.

The favorites tab

Keeping Track of Two Models

If you try to make a currency a favorite and add an amount of it to the wallet, you'll see the heart turn green. That's green for cash, apologies to those who live places with colorful money.

Unfortunately, as you navigate through the app, the heart may only be red, or may switch between red and green, depending on the state of the Wallet at the time that the widget builds.

Showing an inconsistent favorite state on two screens due to wallet

The solution for this is to also provide the wallet's updates along with the favorites to the various view models to compute the correct color for the heart icons. You can do this by nesting a new ChangeNotifierProvider for the wallet as the child of the favorites provider. Nesting providers makes code that is hard to read.

Fortunately, the Provider package provides a syntactically nice helper in the form of MultiProvider. It lets you create multiple providers at once in an array.

Open lib/main.dart.

Replace buildBody() with:

Widget buildBody() {
  final exchange = Exchange(service: CurrencyServiceLocal())..load();
  final storage = StorageServiceLocal();
  // 1
  return MultiProvider(
    // 2
    providers: [
      // 3
      ChangeNotifierProvider<Favorites>(create:
          (_) => Favorites(storage: storage)
      ),
      // 4
      ChangeNotifierProvider<Wallet>(create:
          (_) => Wallet(exchange: exchange, storage: storage)
      ),
    ],
    // 5
    child: Consumer2<Favorites, Wallet>(
      // 6
      builder: (_, favorites, wallet, __)
      => buildTabBar(exchange, favorites, wallet),
    ),
  );
}

This updated method replaces what you did before to make use of MultiProvider. Take note of the following lines of code:

  1. MultiProvider is a Provider widget that simplifies a hierarchy of nested providers into a list.
  2. The providers list is an arbitrary list of providers, here there are two of them.
  3. The Favorites provider is the same as it was before.
  4. Now the Wallet creation is wrapped in a new ChangeNotifierProvider. Wallet will be updated below to be a ChangeNotifier.
  5. Consumer widgets can be used anywhere down the tree and can be nested as well. To keep the code simpler, there are handy ConsumerN widgets for consuming multiple providers in a single block. Here both the Wallet and Favorites providers can be used in the same builder. Note that the order of the types will match the parameters of the builder.
  6. The provided values are available in the builder function along with the build context just like the single consumer.

Now that the main view is wired up, you need to add ChangeNotifier to Wallet for the app to compile.

Open lib/services/user/wallet.dart and replace // TODO: add import with:

import 'package:flutter/foundation.dart';

Then update the class definition, by replacing the line under // TODO: update class definition with:

class Wallet with ChangeNotifier {

And finally, replace all the //TODO: add notifyListeners lines with:

notifyListeners(); 

You should have found three places to update:

  1. In the constructor, at the end of the storage loadWallet() completion block.
  2. At the end of the constructor.
  3. At the end of save().

Rebuild and run the app. The favorites and wallet models will update across the app. Changes in the detail page will reflect on all three tabs. You'll now see the properly colored hearts on the currency list and dollar totals on the wallet tab.

Green hearts updating properly now on currency list

A working wallet tab