30 Why We Need State Management and How to Do It

30 Why We Need State Management and How to Do It #

Hello, I am Chen Hang.

In the previous article, I shared with you how to manage the hybrid navigation stack in a native Flutter project to handle page transitions across rendering engines. This resolves the problem of switching from native pages to Flutter pages and vice versa.

If the key to cross-rendering page transitions is to ensure a consistent rendering experience, then the key to sharing data between components (pages) is to maintain the shared data state of the components clearly. In the 20th article “Three Techniques for Data Transfer Between Components,” I introduced three data transfer mechanisms: InheritedWidget, Notification, and EventBus. These mechanisms allow one-way data transfer between components.

If our application is simple enough and the direction and order of data flow are clear, we only need to map the data to the view. As a declarative framework, Flutter can automatically handle the entire process from data to rendering, and usually does not require state management.

However, as the product requirements iterate faster and the project becomes larger, we often need to manage the shared data relationships between different components and pages. When there are dozens or even hundreds of data relationships that need to be shared, it becomes difficult to maintain a clear direction and order of data flow, leading to nested data transfers and numerous callbacks within the application. At this point, we urgently need a solution to help us manage these shared data relationships, and that’s where state management frameworks come into play.

Flutter drew inspiration from React’s design principles in designing its declarative UI, which led to the emergence of state management frameworks such as flutter_redux, flutter_mobx, and fish_redux, which are based on frontend design concepts. However, these frameworks are relatively complex and require a certain understanding of their design concepts, which makes them more difficult to learn.

Meanwhile, Provider, a state management framework developed by the Flutter team, is relatively simpler. It is not only easy to understand, but also has a small footprint. It also allows for seamless composition and control over UI refresh granularity. Therefore, since its introduction at the Google I/O 2019 conference, Provider has become one of the recommended state management approaches.

So today, let’s talk about how to use Provider.

Provider #

As the name suggests, Provider is a framework for providing data. It is a syntax sugar for InheritedWidget and provides dependency injection functionality, allowing for more flexible handling and passing of data in the widget tree.

So, what is dependency injection? In simple terms, dependency injection is a mechanism that allows us to retrieve the required resources when needed. It involves pre-allocating a certain “resource” in a location accessible to all parts of the program. When this “resource” is needed, it can be retrieved from this location without having to worry about who put it there.

In order to use Provider, we need to address the following three questions:

  • How do we encapsulate resources (i.e., data states)?
  • Where do we place the resources so that they can be accessed?
  • How do we retrieve the resources when using them?

Next, I will demonstrate how to use Provider with an example.

In the example below, we have two independent pages, FirstPage and SecondPage, which share the state of a counter. FirstPage is responsible for reading the counter, while SecondPage is responsible for both reading and writing.

Before using Provider, we first need to add the Provider dependency to the pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  provider: 3.0.0+1  # provider dependency

Once the Provider dependency is added, we can proceed with encapsulating the data state. Here, we only have one state to share, which is the count. Since the second page also needs to modify the state, the encapsulation of the data state needs to include a method for changing the data:

// Define the data model to be shared and manage listeners with mixin ChangeNotifier
class CounterModel with ChangeNotifier {
  int _count = 0;
  
  // Getter
  int get counter => _count; 
  
  // Setter
  void increment() {
    _count++;
    notifyListeners(); // Notify listeners to refresh
  }
}

As you can see, we use the mixin ChangeNotifier in the resource encapsulation class. This class helps us manage all listeners of the dependent resources. When the resource encapsulation class calls notifyListeners, it notifies all listeners to refresh.

Now that the resources are encapsulated, the next step is to decide where to place them.

Since Provider is essentially a syntax sugar for InheritedWidget, if we look at the data flow, the data passed through Provider goes from parent to child (or vice versa). This means that we need to place the resources in the parent widget of FirstPage and SecondPage, which is the instance of MyApp (of course, placing the resources at a higher level, such as in the main function, is also possible):

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
     // Encapsulate data resources with the Provider component
    return ChangeNotifierProvider.value(
        value: CounterModel(), // Shared data resource
        child: MaterialApp(
          home: FirstPage(),
        )
    );
  }
}

As Provider is a Widget, just like InheritedWidget, we can wrap it around the MaterialApp directly to inject the data resource into the application.

It is important to note that since the encapsulated data resource needs to provide both read and write capabilities to the child widget, we need to use the upgraded version of Provider, ChangeNotifierProvider. If we only need to provide read capability to the child widget, we can simply use Provider.

Finally, after injecting the data resource, we can perform read and write operations on the data in the FirstPage and SecondPage widgets.

For reading data, similar to InheritedWidget, we can use the Provider.of method to retrieve the resource data. If we want to write data, we need to call the exposed data update method (in this example, it is increment) with the retrieved resource data. The code is as follows:

// First page, responsible for reading data
class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Retrieve the resource
    final _counter = Provider.of<CounterModel>(context);
    return Scaffold(
      // Display data from the resource
      body: Text('Counter: ${_counter.counter}'),
      // Navigate to SecondPage
      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (context) => SecondPage()))
      ));
  }
}

// Second page, responsible for reading and writing data
class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Retrieve the resource
    final _counter = Provider.of<CounterModel>(context);
    return Scaffold(
      // Display data from the resource
      body: Text('Counter: ${_counter.counter}'),
      // Set the button's click callback using the resource update method
      floatingActionButton: FloatingActionButton(
          onPressed: _counter.increment,
          child: Icon(Icons.add),
     ));
  }
}

Run the code and click the “+” button on the second page multiple times, then close the second page. You will see that the first page also updates with the number of button clicks.

Figure 1 Provider usage example

Consumer #

As you can see from the examples above, using Provider.of to access resources allows you to obtain read and write access to the exposed data. It is relatively simple to implement data sharing and synchronization. However, abusing the Provider.of method can have side effects, such as causing other sub-widgets on the page to refresh when the data is updated.

To verify this, let’s perform a test using the “+” icon in the bottom right corner of the second page as an example.

First, in order to print the refresh status of the Icon widget each time it is refreshed, we need to define a custom widget called TestIcon and print a statement in its build method:

// Custom widget used to print the execution status of the build method
class TestIcon extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print("TestIcon build");
    return Icon(Icons.add); // Return an instance of Icon
  }
}

Next, let’s replace the Icon sub-widget of the FloatingActionButton in the SecondPage with the TestIcon widget:

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Get the shared data resource
    final _counter = Provider.of<CounterModel>(context);
    return Scaffold(
     ...
      floatingActionButton: FloatingActionButton(
          onPressed: _counter.increment,
          child: TestIcon(), // Replace the original Icon(Icons.add)
     ));
  }
}

Run this example and click the “+” button on the second page multiple times, and observe the console output:

I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build
I/flutter (21595): TestIcon build

As you can see, the TestIcon widget is originally a StatelessWidget that does not need to be refreshed. However, due to the change in the counter data resource that its parent widget, FloatingActionButton, depends on, it also needs to be refreshed.

So, is there a way to only refresh the widgets that have a dependency on the data resource when it changes, while keeping other widgets unchanged?

The answer is, of course, yes.

At the beginning of this sharing, I mentioned that Provider can precisely control the granularity of UI refresh, and all of this is implemented based on Consumer. Consumer uses the Builder pattern to create the UI and rebuild the widget when it receives a update notification.

Next, let’s see how to use Consumer to refactor the SecondPage.

In the following example, we removed the Provider.of statement to access the counter in the SecondPage. Instead, we use Consumer to wrap the two widgets that really need this data resource, Text and FloatingActionButton:

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // Use Consumer to encapsulate reading the counter
      body: Consumer<CounterModel>(
        // The builder function can directly access the counter parameter
        builder: (context, CounterModel counter, _) => Text('Value: ${counter.counter}')),
      // Use Consumer to encapsulate reading the increment
      floatingActionButton: Consumer<CounterModel>(
        // The builder function can directly access the increment parameter
        builder: (context, CounterModel counter, child) => FloatingActionButton(
          onPressed: counter.increment,
          child: child,
        ),
        child: TestIcon(),
      ),
    );
  }
}

As you can see, the builder within the Consumer is the function that actually refreshes the UI. It receives three parameters: context, model, and child. Among them, context is the BuildContext passed into the build method of the widget, model is the data resource we need, and child is used to build those parts that are unrelated to the data resource. When the data resource changes, the builder will be executed multiple times, but the child will not be rebuilt.

Run this code, and you will find that no matter how many times you click the “+” button, the TestIcon widget will never be destroyed and rebuilt.

Encapsulation of Multiple States #

From the example above, we learned how Provider shares a single data state. So, how do we handle sharing multiple data states?

Actually, it’s not difficult. Next, I will introduce to you the sharing of multiple data states through the three steps of encapsulation, injection, and reading and writing.

Before handling the sharing of multiple data states, we need to first extend the example of sharing the counter state to make the Text that displays the counter data between the two pages able to share the font size passed by the App.

Firstly, let’s talk about encapsulation.

Encapsulation of multiple data states is no different from encapsulating a single data state. If we need to support reading and writing of data, we need to encapsulate a separate resource encapsulation class for each data state. If the data is read-only, we can directly pass in the original data object, thereby eliminating the process of resource encapsulation.

Next, let’s talk about injection.

In the example of a single state, we used the upgraded version of Provider, ChangeNotifierProvider, to achieve injection of readable and writable resources. If we want to inject multiple resources, we can use another upgraded version of Provider, MultiProvider, to achieve the combination injection of multiple providers.

In the following example, we injected two resource providers, double and CounterModel, into the App instance using MultiProvider:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider.value(value: 30.0),// Inject font size
        ChangeNotifierProvider.value(value: CounterModel())// Inject counter instance
      ],
      child: MaterialApp(
        home: FirstPage(),
      ),
    );
  }
}

After completing the injection of multiple resources, let’s see how to access these resources.

Here, we still use the Provider.of method to access the resources. Compared to accessing a single state resource, when accessing multiple resources, we only need to read each resource one by one:

final _counter = Provider.of<CounterModel>(context);// Get counter instance
final textSize = Provider.of<double>(context);// Get font size

If we want to access resources using the Consumer approach, we can use the Consumer2 object (this object provides the ability to read two data resources) to get both the font size and the counter instance:

// Using Consumer2 to get two data resources
Consumer2<CounterModel, double>(
  // The builder function provides the data resources as arguments
  builder: (context, CounterModel counter, double textSize, _) => Text(
      'Value: ${counter.counter}', 
      style: TextStyle(fontSize: textSize)
    ),
)

As you can see, the usage of Consumer2 is similar to Consumer, except that the builder method has an additional data resource parameter. In fact, if you want to share more data in the child widget, we can use up to Consumer6 to share six data resources.

Summary #

Alright, that’s it for today’s sharing. Let’s summarize the main points we covered today.

I introduced to you the method of state management using Provider in Flutter. Provider, in the form of syntactic sugar for InheritedWidget, allows us to share data across components (or pages) through the three steps of data encapsulation, data injection, and data reading and writing.

We can use Provider to achieve static data sharing, use ChangeNotifierProvider to achieve dynamic data sharing with read and write capabilities, and use MultiProvider to share multiple data resources.

When using the data, we can read the data using Provider.of or Consumer, and Consumer can also control the granularity of UI updates to avoid unnecessary updates in components unrelated to the data.

As you can see, by using Provider for data sharing, whether within a single page or across the entire app, we can easily achieve state management and handle scenarios that cannot be achieved with StatefulWidget, resulting in simple, well-structured, and highly scalable applications. In fact, once we start using Provider, we no longer need to use StatefulWidget.

I have packaged all the knowledge points covered in today’s sharing on GitHub. You can download it and run it multiple times to deepen your understanding and memory.

Thought-provoking Question #

Finally, I’ll leave you with a thought-provoking question.

Using Provider, how can you achieve object sharing between two objects of the same type? Do you know how to implement it?

Please leave your thoughts in the comments section. I’ll be waiting for you in the next article! Thank you for listening, and feel free to share this article with more friends for them to read along.