Official Simple app state management does an excellent job on describing how you can manage state in your Flutter app using provider and ChangeNotifier.

In addition to everything that was mentioned in that article, there are a couple of things that are worth remembering once your app and screens start growing bigger.

Limiting number of ChangeNotifier’s listeners

As the official documentation states:

ChangeNotifier is optimized for small numbers (one or two) of listeners. It is O(N) for adding and removing listeners and O(N²) for dispatching notifications (where N is the number of listeners).

which means we should be wary about having too many listeners of our ChangeProvider.

On the other hand, Provider’s complexity for dispatching notifications to its dependents eg. Consumers, Selectors, ProxyProviders - in other words, Widgets calling Provider.of underneath - is O(N), as confirmed by Remi Rousselet here.

That’s why it is an optimal combination to have one instance of ChangeNotifierProvider per each instance of ChangeNotifier and having the ChangeNotifierProvider dispatch changes to all its dependents.

ChangeNotifierProvider was already suggested in the above-mentioned article, but I wanted to elaborate more why is it worth using - not only does it reduce boilerplate code, but it’s also an optimal solution.

Sometimes, if you need to use same instance of a certain ChangeNotifier across many different screens, it might be optimal to provide it above entire MaterialApp - or any other top widget like CupertinoApp - for example:

Note that putting a ChangeNotifier above MaterialApp effectively makes it a singleton, so this solution shouldn’t be overused. Usually, apps will have one ChangeNotifier per Screen or Tab.

Using Selector for optimal granular updates

The more seldom and granular updates we have, the better the performance is. That’s why it’s often better to use Selector instead of Consumer.

As the documentation states, Selector is:

an equivalent to Consumer that can filter updates by selecting a limited amount of values and prevent rebuild if they don’t change.

Consumer is widely known, mostly because it’s usually used in the examples everywhere and that’s very often good enough, but usually using Selector is the optimal solution.

So let’s consider the following example:

We have Person data class consisting of 2 other data classes: Name and Address :

And we also have some kind of PersonWidget that expects to get Person from the parent tree and present it in the UI:

As you can see, in the current implementation NameWidget will be rebuilt not only when name changes, but also every single time when address changes.

Similarly, AddressWidget will be rebuilt when either name or address change.

Which is clearly suboptimal.

And if NameWidget or AddressWidget were complex Widgets, that could significantly impact performance of our widget tree.

So let’s improve it by using Selector:

Now NameWidget will be rebuilt only when name changes and AddressWidget will be rebuilt only when address changes, so we’re all good.

Not mixing Business Logic with Model Logic

If in our ChangeNotifier we’re putting both all things needed to represent state (model logic) and all things needed for business rules (business logic), the ChangeNotifier might quickly grow and become hard to maintain or read.

And then there are also some ambiguities like what should ChangeNotifier’s equals() or toString() return? Only information about the model? Or also some information about the ChangeNotifier?

So let’s look at the following example:

Here fields needed by BasketChangeNotifier are mixed up with fields actually used by the UI.

The == method considers properties needed by the BasketChangeNotifier which can make developer experience during debugging worse.

And these issues might seem insignificant, especially when your ChangeNotifier is small, but once the ChangeNotifier gets bigger or we start to have multiple people working on our app, splitting it into 2 entities can help with defining more clearly what goes where and can improve overall readability of the presentation logic.

So let’s split it into 2 separate classes to fix these issues:

Business Logic:

Model Logic:

Bonus: From this point on it’s also much easier to represent all possible states using sealed classes. If you’re unfamiliar with this concept, I can highly recommend the Representing State by Christina Lee talk. The talk is based in Kotlin world, but the idea stays the same. Dart doesn’t have sealed classes yet, but there are packages helping with that, for example: freezed.

Defining strict analysis rules

Default Dart analyser rules can be quite surprising in many aspects.

One example is when not returning defined return type from a function, which results in just a hint instead of an error:

To be precise, this method will return null, but usually it’s a developer’s mistake to not return anything from a method with type different than void.

Another example are implicit casts , because of which code like this:

will compile completely fine, but will result in runtime error saying:

type ‘WhereIterable<int>’ is not a subtype of type ‘List<int>’

In order to fix these and improve many other issues, we can define our own analysis rules. This is somewhat subjective, so feel free to adjust them as you like. I’m usually using a combination of pedantic and a few custom rules:

Additional resources