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:
Various events must be explicitly defined, which is cumbersome to manage.
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:
How to notify when the data changes?
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:
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
:
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:
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.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!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 onCartModel
within the app.The semantics are unclear; since
ChangeNotifierProvider
is a subscriber, any widget that depends onCartModel
is naturally a subscriber and a consumer of the state. If we useBuilder
for this purpose, the semantics are not very clear. If we could use a widget with clear semantics, such as one calledConsumer
, the final code would have much clearer semantics—just by seeingConsumer
, 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 Name | Description |
---|---|
Provider & Scoped Model | Both packages are based on InheritedWidget , and their principles are similar. |
Redux | The Flutter implementation of the Redux package from the React ecosystem in web development. |
MobX | The Flutter implementation of the MobX package from the React ecosystem in web development. |
BLoC | The 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
.