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
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:
Person data class consisting of 2 other data classes:
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
AddressWidget will be rebuilt when either
Which is clearly suboptimal.
AddressWidget were complex Widgets, that could significantly impact performance of our widget tree.
So let’s improve it by using Selector:
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
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.
== 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:
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:
- There isn’t one and only right architecture. If you’re interested in exploring other possible approaches, there are many solutions available out there like: BLoC, MobX and many others
- Service Locators / Dependency Injection libraries, I have personally always used get_it, but there are also some other interesting options for example: kiwi or inject
- Preferring classes over methods for creating reusable widget trees, more information about it can be found here and here