Flutter (51): Sharing state across components

Time: Column:Mobile & Frontend views:255

51.1 Synchronizing State through Events

In Flutter development, state management is an enduring topic. The general principle is: if the state is private to a component, it should be managed by that component itself; if the state needs to be shared across components, it should be managed by a common parent element of those components. Managing private state for a component is straightforward, but for state shared across components, there are various management approaches. For instance, using a global event bus like EventBus (which will be introduced in the next chapter) implements the observer pattern, enabling state synchronization across components: the state holder (publisher) is responsible for updating and publishing the state, while the state user (observer) listens for state change events to perform certain actions. Below is a simple example of synchronizing login state:

Defining Events:

enum Event {
  login,
  ... // Other events omitted
}

The login page code is roughly as follows:

// Publish the state change event after the login state changes
bus.emit(Event.login);

Pages that depend on the login state:

void onLoginChanged(e) {
  // Logic to handle login state change
}

@override
void initState() {
  // Subscribe to login state change events
  bus.on(Event.login, onLogin);
  super.initState();
}

@override
void dispose() {
  // Unsubscribe
  bus.off(Event.login, onLogin);
  super.dispose();
}

We can see that using the observer pattern to implement cross-component state sharing has some obvious drawbacks:

  1. Various events must be explicitly defined, which is cumbersome to manage.

  2. Subscribers must explicitly register state change callbacks and also manually unbind the callbacks when the component is destroyed to avoid memory leaks.

Is there a better way to manage cross-component state in Flutter? The answer is yes! So how can we do it? Let’s think back to the InheritedWidget introduced earlier. Its innate characteristic is the ability to bind the dependencies between the InheritedWidget and its dependent descendant components. When the data of the InheritedWidget changes, it can automatically update the dependent descendant components! Utilizing this feature, we can store the states that need to be shared across components in the InheritedWidget, and then reference it in the child components. The well-known Provider package in the Flutter community is based on this concept and provides a solution for cross-component state sharing. Next, we will introduce the usage and principles of Provider in detail.

51.2 Provider

Provider is an official state management package for Flutter. To enhance readers' understanding of its principles, instead of directly looking at the source code of the Provider package, I will guide you step by step to implement a minimally functional Provider based on the approach described above using InheritedWidget.

1. Implementing Your Own Provider

First, we need an InheritedWidget that can store shared data. Since the specific business data types are unpredictable, we will use generics to define a generic InheritedProvider class that extends InheritedWidget:

// A generic InheritedWidget to store state that needs to be shared across components
class InheritedProvider<T> extends InheritedWidget {
  InheritedProvider({
    required this.data,
    required Widget child,
  }) : super(child: child);

  final T data;

  @override
  bool updateShouldNotify(InheritedProvider<T> old) {
    // Simply return true to trigger `didChangeDependencies` for dependent descendant nodes on every update.
    return true;
  }
}

Now that we have a place to store the data, we need to rebuild the InheritedProvider when the data changes. This brings us to two questions:

  1. How to notify when the data changes?

  2. Who will rebuild the InheritedProvider?

The first question is quite straightforward. We could use the event bus introduced earlier for event notification, but to align more closely with Flutter development, we will use the ChangeNotifier class provided in the Flutter SDK. ChangeNotifier extends Listenable and implements a Flutter-style publisher-subscriber pattern. The definition of ChangeNotifier is roughly as follows:

class ChangeNotifier implements Listenable {
  List listeners = [];
  
  @override
  void addListener(VoidCallback listener) {
     // Add listener
     listeners.add(listener);
  }

  @override
  void removeListener(VoidCallback listener) {
    // Remove listener
    listeners.remove(listener);
  }
  
  void notifyListeners() {
    // Notify all listeners, triggering their callbacks
    listeners.forEach((item) => item());
  }
   
  ... // Other irrelevant code omitted
}

We can add or remove listeners (subscribers) by calling addListener() and removeListener(), and we can trigger all listener callbacks by calling notifyListeners().

Now, we will place the state we want to share into a Model class and make it extend ChangeNotifier. This way, when the shared state changes, we only need to call notifyListeners() to notify subscribers, which also answers the second question! Next, we will implement this subscriber class:

class ChangeNotifierProvider<T extends ChangeNotifier> extends StatefulWidget {
  ChangeNotifierProvider({
    Key? key,
    this.data,
    this.child,
  });

  final Widget child;
  final T data;

  // A convenience method for widgets in the subtree to access shared data
  static T of<T>(BuildContext context) {
    final type = _typeOf<InheritedProvider<T>>();
    final provider = context.dependOnInheritedWidgetOfExactType<InheritedProvider<T>>();
    return provider.data;
  }

  @override
  _ChangeNotifierProviderState<T> createState() => _ChangeNotifierProviderState<T>();
}

This class extends StatefulWidget and defines a static method of() for subclasses to easily access the shared state (model) stored in the InheritedProvider. Next, we will implement the corresponding _ChangeNotifierProviderState class:

class _ChangeNotifierProviderState<T extends ChangeNotifier> extends State<ChangeNotifierProvider<T>> {
  void update() {
    // If the data changes (the model class calls notifyListeners), rebuild the InheritedProvider
    setState(() => {});
  }

  @override
  void didUpdateWidget(ChangeNotifierProvider<T> oldWidget) {
    // When the Provider updates, if the new and old data are not equal, unbind the old data listener and add the new data listener
    if (widget.data != oldWidget.data) {
      oldWidget.data.removeListener(update);
      widget.data.addListener(update);
    }
    super.didUpdateWidget(oldWidget);
  }

  @override
  void initState() {
    // Add a listener to the model
    widget.data.addListener(update);
    super.initState();
  }

  @override
  void dispose() {
    // Remove the model's listener
    widget.data.removeListener(update);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return InheritedProvider<T>(
      data: widget.data,
      child: widget.child,
    );
  }
}

The main purpose of the _ChangeNotifierProviderState class is to rebuild the widget tree when the shared state (model) changes. Note that when calling the setState() method in the _ChangeNotifierProviderState class, the widget.child is always the same. Therefore, when executing build, the child referenced by InheritedProvider remains the same widget, so widget.child does not trigger a rebuild, effectively caching the child! However, if the parent widget of ChangeNotifierProvider rebuilds, the child passed to it may change.

Now that we have completed the necessary utility classes, let's look at how to use these classes through a shopping cart example.

2. Shopping Cart Example

We need to implement a feature that displays the total price of all items in the shopping cart:

  • Update the total price when a new item is added to the cart.

Define an Item class to represent item information:

class Item {
  Item(this.price, this.count);
  double price; // Price per item
  int count; // Quantity of items
  //... Other properties omitted
}

Define a CartModel class to save the item data in the shopping cart:

class CartModel extends ChangeNotifier {
  // List to store items in the shopping cart
  final List<Item> _items = [];

  // Prevents modification of item information in the cart
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  // Total price of items in the cart
  double get totalPrice =>
      _items.fold(0, (value, item) => value + item.count * item.price);

  // Adds [item] to the cart. This is the only way to modify the cart from the outside.
  void add(Item item) {
    _items.add(item);
    // Notify listeners (subscribers) to rebuild the InheritedProvider and update the state.
    notifyListeners();
  }
}

CartModel is the model class that needs to be shared across components. Finally, we construct the example page:

class ProviderRoute extends StatefulWidget {
  @override
  _ProviderRouteState createState() => _ProviderRouteState();
}

class _ProviderRouteState extends State<ProviderRoute> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: ChangeNotifierProvider<CartModel>(
        data: CartModel(),
        child: Builder(builder: (context) {
          return Column(
            children: <Widget>[
              Builder(builder: (context) {
                var cart = ChangeNotifierProvider.of<CartModel>(context);
                return Text("Total Price: ${cart.totalPrice}");
              }),
              Builder(builder: (context) {
                print("ElevatedButton build"); // Used in later optimization
                return ElevatedButton(
                  child: Text("Add Item"),
                  onPressed: () {
                    // Add item to the cart, total price will update after addition
                    ChangeNotifierProvider.of<CartModel>(context).add(Item(20.0, 1));
                  },
                );
              }),
            ],
          );
        }),
      ),
    );
  }
}

After running the example, the effect is shown in Figure:

Flutter (51): Sharing state across components

Every time the "Add Item" button is clicked, the total price increases by 20, and we have achieved the desired functionality! Some readers may wonder if it's meaningful to go through such a roundabout way to implement such a simple feature. In fact, for this example, we are only updating a state within the same route page, and the advantages of using ChangeNotifierProvider are not very evident. However, what if we are building a shopping app? Since shopping cart data is typically shared throughout the app, for instance, across routes, if we place ChangeNotifierProvider at the root of the entire application widget tree, then the entire app can share the shopping cart data. This is where the advantages of ChangeNotifierProvider become very clear.

Although the above example is relatively simple, it clearly illustrates the principles and processes of Provider. Figure shows the principles of Provider:

Flutter (51): Sharing state across components

When the model changes, it automatically notifies the ChangeNotifierProvider (the subscriber), which then rebuilds the InheritedWidget. The descendant widgets that depend on this InheritedWidget will update accordingly.

We can see that using Provider will bring the following benefits:

  1. Our business logic focuses more on the data. As long as the model is updated, the UI will automatically refresh without needing to explicitly call setState() after the state changes.

  2. The message passing for data changes is abstracted away. We no longer need to manually handle the publication and subscription of state change events; everything is encapsulated within Provider. This is truly great and saves us a lot of work!

  3. In large, complex applications, especially those with many globally shared states, using Provider will significantly simplify our code logic, reduce the likelihood of errors, and improve development efficiency.

51.3 Optimization

The ChangeNotifierProvider we implemented above has two obvious drawbacks: code organization issues and performance issues. Let's discuss these one by one.

1. Code Organization Issues

Let's take a look at the code that builds the total price Text:

Builder(builder: (context) {
  var cart = ChangeNotifierProvider.of<CartModel>(context);
  return Text("Total Price: ${cart.totalPrice}");
})

This piece of code has two points that can be optimized:

  • It requires explicitly calling ChangeNotifierProvider.of, which becomes quite redundant when many widgets depend on CartModel within the app.

  • The semantics are unclear; since ChangeNotifierProvider is a subscriber, any widget that depends on CartModel is naturally a subscriber and a consumer of the state. If we use Builder for this purpose, the semantics are not very clear. If we could use a widget with clear semantics, such as one called Consumer, the final code would have much clearer semantics—just by seeing Consumer, we would know it depends on some cross-component or global state.

To optimize these two issues, we can encapsulate a Consumer widget, implemented as follows:

// This is a convenient class that gets the current context and the specified data type's Provider
class Consumer<T> extends StatelessWidget {
  Consumer({
    Key? key,
    required this.builder,
  }) : super(key: key);

  final Widget Function(BuildContext context, T? value) builder;

  @override
  Widget build(BuildContext context) {
    return builder(
      context,
      ChangeNotifierProvider.of<T>(context),
    );
  }
}

The implementation of Consumer is very straightforward. It specifies the template parameter and automatically calls ChangeNotifierProvider.of internally to retrieve the relevant model. Moreover, the name Consumer itself carries a precise meaning (consumer). Now the previous code block can be optimized as follows:

Consumer<CartModel>(
  builder: (context, cart) => Text("Total Price: ${cart.totalPrice}"),
)

Isn't it elegant!

2. Performance Issues

The above code has a performance issue at the part where the "Add Button" is constructed:

Builder(builder: (context) {
  print("ElevatedButton build"); // Log output during construction
  return ElevatedButton(
    child: Text("Add Item"),
    onPressed: () {
      ChangeNotifierProvider.of<CartModel>(context).add(Item(20.0, 1));
    },
  );
})

When we click the "Add Item" button, the total price of the items in the cart changes, so updating the text displaying the total price is as expected. However, the "Add Item" button itself does not change and should not be rebuilt. Yet, when we run the example, every time we click the "Add Item" button, the console outputs the log "ElevatedButton build." This means that the "Add Item" button is being rebuilt every time it is clicked! Why is this happening? If you understand the update mechanism of InheritedWidget, the answer becomes clear: This is because the Builder constructing the ElevatedButton calls ChangeNotifierProvider.of, creating a dependency on the InheritedWidget (i.e., InheritedProvider) above in the widget tree. Therefore, when an item is added and the CartModel changes, it notifies the ChangeNotifierProvider, which then rebuilds the subtree. Consequently, the InheritedProvider will update, and any descendant widgets that depend on it will also be rebuilt.

Now that we understand the cause of the issue, how can we avoid this unnecessary reconstruction? Since the button is being rebuilt due to its dependency on the InheritedWidget, we just need to break or remove this dependency. But how can we do that? As mentioned in the previous section about InheritedWidget, the difference between calling dependOnInheritedWidgetOfExactType() and getElementForInheritedWidgetOfExactType() is that the former registers a dependency, while the latter does not. Therefore, we only need to modify the implementation of ChangeNotifierProvider.of as follows:

// Add a listen parameter indicating whether to establish a dependency
static T of<T>(BuildContext context, {bool listen = true}) {
  final type = _typeOf<InheritedProvider<T>>();
  final provider = listen
      ? context.dependOnInheritedWidgetOfExactType<InheritedProvider<T>>()
      : context.getElementForInheritedWidgetOfExactType<InheritedProvider<T>>()?.widget
          as InheritedProvider<T>;
  return provider.data;
}

Then we change the calling part of the code to:

Column(
  children: <Widget>[
    Consumer<CartModel>(
      builder: (BuildContext context, cart) => Text("Total Price: ${cart.totalPrice}"),
    ),
    Builder(builder: (context) {
      print("ElevatedButton build");
      return ElevatedButton(
        child: Text("Add Item"),
        onPressed: () {
          // Set listen to false to avoid establishing a dependency
          ChangeNotifierProvider.of<CartModel>(context, listen: false).add(Item(20.0, 1));
        },
      );
    })
  ],
)

After modifying and running the example again, we will find that clicking the "Add Item" button no longer outputs "ElevatedButton build" in the console, meaning the button will not be rebuilt. Meanwhile, the total price will still update because the value of listen is true by default when calling ChangeNotifierProvider.of in Consumer, so the dependency will still be established.

At this point, we have implemented a mini Provider, which possesses the core functionalities of the Provider Package available on Pub. However, our mini-version is not comprehensive; it only implements a ChangeNotifierProvider that can be listened to and does not implement a provider solely for data sharing. Additionally, our implementation does not consider certain boundaries, such as how to ensure that the model remains a singleton when the widget tree is rebuilt. Therefore, it is recommended that readers use the Provider Package in practical applications, while the main purpose of implementing this mini-provider in this section is to help readers understand the underlying principles of the Provider Package.

51.4 Other State Management Packages

The Flutter community now has many packages specifically for state management. Here are a few that are relatively well-rated:

Package NameDescription
Provider & Scoped ModelBoth packages are based on InheritedWidget, and their principles are similar.
ReduxThe Flutter implementation of the Redux package from the React ecosystem in web development.
MobXThe Flutter implementation of the MobX package from the React ecosystem in web development.
BLoCThe Flutter implementation of the BLoC pattern.

I do not recommend any of these packages here; readers interested can research them to understand their respective philosophies.

51.5 Summary

In this section, we introduced some drawbacks of the event bus in cross-component sharing, leading to the idea of using InheritedWidget for state sharing. Based on this idea, we implemented a simple Provider and explored the registration and update mechanisms of InheritedWidget and its dependencies in depth. By studying this section, readers should achieve two goals: first, to thoroughly understand InheritedWidget, and second, to grasp the design philosophy of Provider.

InheritedWidget is a crucial widget in Flutter, and features such as internationalization and themes are implemented using it. Hence, we dedicated several sections to introduce it. In the next section, we will introduce another component based on InheritedWidget: Theme.