Flutter.dev: Simple application state management

Hello. In September, OTUS is launching a new course , Flutter Mobile Developer . On the eve of the start of the course, we have traditionally prepared a useful translation for you.








Now that you know about declarative user interface programming and the difference between ephemeral state and application state , you are ready to learn how to easily manage application state.



We will use the package provider. If you're new to Flutter and don't have a compelling reason to choose a different approach (Redux, Rx, hooks, etc.), this is probably the best approach to get started. The package provider is easy to learn and doesn't require a lot of code. He also operates with concepts that are applicable in all other approaches.



However, if you already have a lot of experience with managing state from other reactive frameworks, you can look for other packages and tutorials listed on the options page .



Example







Consider the following simple application as an example.



The application has two separate screens: catalog and shopping cart (represented by widgets MyCatalogand MyCartrespectively). In this case, this is a shopping app, but you can imagine the same structure in a simple social networking app (replace the catalog with โ€œwallโ€ and cart with โ€œfavoritesโ€).



The catalog screen includes a customizable application bar ( MyAppBar) and a scrolling view of multiple list items ( MyListItems).



Here is the application in the form of a tree of widgets:







So, we have at least 5 subclasses Widget. Many of them need access to state that they do not own. For example, eachMyListItemshould be able to add yourself to the cart. They may also need to check if the currently displayed item is in the cart.



This brings us to our first question: where should we put the current state of the bucket?



Increasing condition



In Flutter, it makes sense to position state above the widgets that use it.



What for? In declarative frameworks like Flutter, if you want to change the user interface, you have to rebuild it. You can't just go and write MyCart.updateWith(somethingNew). In other words, it is difficult to force change the widget from the outside by calling a method on it. And even if you could get it to work, you would be fighting the framework instead of letting it help you.



// :   
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}




Even if you get the above code to work, you have to deal MyCartwith the following in the widget :



// :   
Widget build(BuildContext context) {
  return SomeWidget(
//   .
  );
}

void updateWith(Item item) {
// -      UI.
}




You will need to take into account the current state of the UI and apply the new data to it. It will be difficult to avoid mistakes here.



In Flutter, you create a new widget every time its content changes. Instead of MyCart.updateWith(somethingNew)(method call), you use MyCart(contents)(constructor). Since you can only create new widgets in their parent's build methods, if you want to change contentsit must be in the parent MyCartor higher.



// 
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}




Now MyCarthas only one code execution path to create any version of the user interface.



// 
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    //     ,    .
    // ยทยทยท
  );
}




In our example, it contentsshould be in MyApp. Each time it changes, it rebuilds the MyCart on top (more on that later). This MyCartway you don't have to worry about the lifecycle - it just declares what to show for any given contents. When it changes, the old widget MyCartwill disappear and be completely replaced by the new one.







This is what we mean when we say that widgets are immutable. They don't change - they are replaced.



Now that we know where to put the bucket state, let's see how to access it.



State access



When a user clicks on one of the items in the catalog, it is added to the cart. But since the cart is over MyListItem, how do we do this?



A simple option is to provide a callback that MyListItemcan be invoked on click. Dart functions are first class objects, so you can pass them in any way you want. So, internally, MyCatalogyou can define the following:



@override
Widget build(BuildContext context) {
  return SomeWidget(
   //  ,      .
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}




This works fine, but for the application state that you need to change from many different places, you will have to pass a lot of callbacks, which becomes boring pretty quickly.



Fortunately, Flutter has mechanisms that allow widgets to provide data and services to their descendants (in other words, not only to their descendants, but any downstream widgets). As you would expect from a Flutter, where Everything is a Widget , these mechanisms are merely special kinds of widgets: InheritedWidget, InheritedNotifier, InheritedModeland others. We will not describe them here because they are slightly out of line with what we are trying to do.



Instead, we're going to use a package that works with low-level widgets but is easy to use. It's called provider.



With, provideryou don't need to worry about callbacks or InheritedWidgets. But you need to understand 3 concepts:



  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer




ChangeNotifier



ChangeNotifierIs a simple class included in the Flutter SDK that provides state change notification to its listeners. In other words, if something is ChangeNotifier, you can subscribe to its changes. (This is a form of Observable - for those who are unfamiliar with the term.)



ChangeNotifierIn provideris one way to encapsulate the state of the application. For very simple applications, you can get by with one ChangeNotifier. In more complex ones, you will have several models and therefore several ChangeNotifiers. (You don't need to use ChangeNotifierwith at all provider, but this class is easy to work with.)



In our sample shopping app, we want to manage the state of the cart in ChangeNotifier. We create a new class that extends it, for example:



class CartModel extends ChangeNotifier {
///    .
  final List<Item> _items = [];

  ///     .
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  ///      ( ,      42 ).
  int get totalPrice => _items.length * 42;

  ///  [item]  .   [removeAll] -     .
  void add(Item item) {
    _items.add(item);
    //    ,    ,   .
    notifyListeners();
  }

  ///     .
  void removeAll() {
    _items.clear();
    //    ,    ,   .
    notifyListeners();
  }
}




The only piece of code specific to ChangeNotifieris the call notifyListeners(). Call this method every time the model changes in such a way that it can be reflected in the UI of your application. Everything else in CartModelis the model itself and its business logic.



ChangeNotifieris part of flutter:foundationand does not depend on any higher level classes in Flutter. It's easy to test (you don't even need to use widget testing for that). For example, here's a simple unit test CartModel:



test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
});




ChangeNotifierProvider



ChangeNotifierProviderIs a widget that provides an instance to ChangeNotifierits children. It comes in a package provider.



We already know where to place it ChangeNotifierProvider: above the widgets that need access to it. In case CartModelit implies something above MyCartand MyCatalog.



You don't want to post ChangeNotifierProviderhigher than necessary (because you don't want to pollute the scope). But in our case, the only widget that is over MyCartand MyCatalog- it MyApp.



void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: MyApp(),
    ),
  );
}




Note that we are defining a constructor that creates a new instance CartModel. ChangeNotifierProvidersmart enough not to rebuild CartModelunless absolutely necessary. It also automatically calls dispose () on the CartModel when the instance is no longer needed.



If you want to provide more than one class, you can use MultiProvider:



void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: MyApp(),
    ),
  );
}




Consumer



Now that it is CartModelprovided to the widgets in our application via the declaration ChangeNotifierProviderat the top, we can start using it.



This is done through a widget Consumer.



return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text("Total price: ${cart.totalPrice}");
  },
);




We have to specify the type of model we want to access. In this case, we need it CartModel, so we write Consumer<CartModel>. If you don't specify generic ( <CartModel>), the package providerwon't be able to help you. provideris type based and without the type it won't understand what you want.



The only required argument to the widget Consumeris builder. Builder is a function that gets called on change ChangeNotifier. (In other words, when you call notifyListeners()on your model, all builder methods of all relevant widgets are Consumercalled.)



The constructor is called with three arguments. The first is context, which you also get in every build method.

The second argument to the builder function is an instanceChangeNotifier... This is what we asked for from the very beginning. You can use the model data to determine how the user interface should look at any given point.



The third argument is child, it is needed for optimization. If you have a large widget subtree under yours Consumerthat doesn't change when the model changes, you can build it once and get it via builder.



return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
        children: [
          //   SomeExhibitedWidget,    .
          child,
          Text("Total price: ${cart.totalPrice}"),
        ],
      ),
  //    .
  child: SomeExpensiveWidget(),
);




It is best to place your Consumer widgets as deep as possible in the tree. You don't want to rebuild large parts of the user interface just because some detail has changed somewhere.



//   
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);




Instead of this:



//  
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);




Provider.of



Sometimes you don't really need the data in the model to change the user interface, but you still need access to it. For example, a button ClearCartallows the user to remove everything from the cart. It is not necessary to display the contents of the cart, just call the method clear().



We could use it Consumer<CartModel>for that, but that would be wasteful. We would ask the framework to rebuild the widget, which doesn't need to be rebuilt.



For this use case, we can use Provider.ofwith the parameter listenset to false.



Provider.of<CartModel>(context, listen: false).removeAll();




Using the above line in the build method will not rebuild this widget when called notifyListeners .



Putting it all together



You can check out the example discussed in this article. If you need something a little simpler, check out what a simple Counter application created with provider looks like .



When you're ready to play with provideryourself, remember to add its dependency to yours first pubspec.yaml.



name: my_name
description: Blah blah blah.

# ...

dependencies:
  flutter:
    sdk: flutter

  provider: ^3.0.0

dev_dependencies:
  # ...




Now you can 'package:provider/provider.dart'; and start building ...






All Articles