Today I learned: Better Ways to Initialize Providers Before Widgets Are Built in Flutter Apps
Initialize Provider state management and execute future functions within it, when a widget is first created.
Earlier this year, I decided to refactor my old code to make it cleaner. And just learn new things from this job. In this article, I will share one of them. It was about Initializing state management before the widget is created. The project I’m working on is using Provider.
In Flutter we can use initState()
to initialize our state variable before the widget is created. I often use this method to invoke the future function. Most of them are methods to fetch data from API.
For example, I have a class and extend it to ChangeNotifier
like below:
class AppState extends ChangeNotifier{
/// loading state
bool _isLoading = false;
bool get isLoading => _isLoading;
set isLoading(bool isload) {
_isLoading = isload;
notifyListeners();
}
/// data
String _txt = "";
String get txt => _txt;
set txt(String newtxt) {
_txt = newtxt;
}
/// Future function
Future<void> fetchSomething({String data ="abc"}) async {
isLoading = true; // notifylistener called inside the setter
// assume this is fetching data from API
await Future.delayed(const Duration(seconds: 4));
_txt = data;
isLoading = false; // notifylistener called inside the setter
}
}
In my old code, I usually use StatefullWidget
and to initialize my state data I will call the fetchSomething()
method inside the initState()
method. See the code below:
@override
void initState() {
super.initState();
context.read<AppState>().fetchSomething();
}
However, the method above will throw an exception. That’s because, in my function after finishing fetching the data, it assigns to the widget, and the widget needs to be rebuilt with current data. The error is:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══
...
setState() or markNeedsBuild() called during build.
This Overlay widget cannot be marked as needing to build because the framework is already in the process of building
addPostFrameCallback()
If you search in Stackoverflow, one of the popular solutions to this issue is to implement addPostFrameCallback
scheduling the callback at the end of the frame. We execute the method when the context is available.
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AppState>().fetchSomething();
});
}
Future.delayed(Duration.zero)
Another trick for this problem is to call it inside Future.delayed.
Our code will schedule a callback to be executed after the widget is created. This means the context is already available in the widget tree. So, we can initialize the Provider.
@override
void initState() {
super.initState();
Future.delayed(Duration.zero).then((_) {
context.read<AppState>().fetchSomething();
});
}
This is one of the recommendations by the creator of Provider. We can use mictotask()
constructor to initialize our state which allows us to pass parameters to the state class.
With this constructor, it will create a future containing the result of calling computation asynchronously with scheduleMicrotask.
Callbacks that registered through scheduleMicrotask
are always in order and are guaranteed to run before other events.
@override
initState() {
super.initState();
Future.microtask(() =>
context.read<AppState>().fetchSomething(data:"lorem");
);
}
The three options above work fine in my code. But after refactoring my code I had the same thought as the provider’s documentation says:
“It is slightly less ideal… 👀”
In my old code (most of them are legacy code), the whole app only has one Notifier class. Yashhh 😝, you can imagine that all of the variables were populated in just one class named AppState{}.
That's why the 3 option above was commonly used in the project.
What about after the refactor?
In my current pattern, I mostly separated the Notifier class and named it as Controller. But I still use AppState{}
to keep the global and shared variable.
For example, I have a form screen to create new Customers. So In my presentation layer, I will have the following:
- presentation
- Customer
- widgets // component widgets
- customer_screen.dart // display list of customer
- form_screen.dart // statefull or stateless widget for form layout
- form_controller.dart // notifier class
Here is my form controller:
class FormController extends ChangeNotifier{
/// data
String _txt = "";
String get txt => _txt;
set txt(String newtxt) {
_txt = newtxt;
}
/// Future function
Future<void> initData({String param1 ="abc"}) async {
isLoading = true;
final data = await fetchSomething();
_txt = data;
isLoading = false;
notifyListener();
}
}
Oke, now here is what I do to initialize my form_controller.
If you see the initData()
method, it has an optional parameter to be passed namedparam1.
So, there are 2 conditions to initialize:
Create constructor directly inside
The first condition is Notifier class doesn't require any external parameters to initialize. This means we use the default value of param1.
In this condition, we can create a constructor directly inside our model.
class FormController extends ChangeNotifier{
FormController(){
initData();
}
/// Future function
Future<void> initData({String param1 ="abc"}) async {}
}
and now, every time I want to navigate to the Form_screen
just need to wrap it with ChagenotifierProvider()
like below:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider(
create: (context) => FormController(), // this will auto runs initData()
builder: (context, child) => const CatalogScreen(),
),
)
);
This line create:(context) => FormController()
will initialize our state and execute initData().
So it’s safer to display the screen, because the state is executed before the widget is created.
The initData()
will only execute once it is created. So, no need to worry about executing unnecessary methods every time you initialize in the screen.
For example, when I want to save the form. In my onPressed
I will initialze my FormController.
ElevatedButton(
onPressed: () {
// in this line, the initData will not executed any more
final state = context.read<FormController>();
await state.save(); // call save the funtion
},
child: const Text("Save form")
),
every time I create an instance from context.read<FormController>()
this will not execute initData()
anymore. If I want to re-execute that method, I just call it like context.read<FormController>().initData().
With external parameters
If initData()
requires external parameters, it is not recommended to initialize it with the constructor as above. Because, whenever we create an instance of our notifier, we must pass the parameter. And I quite dislike it.
So, the solution to this problem I found an example from another repository. Actually, this is also recommended by the Creator of Provider Remi Rousselet. And this way matches the architecture pattern in my project.
Since the initData()
required external parameter, the Notifier class will be like this:
class FormController extends ChangeNotifier{
// we need change to be like this
FormController();// you also can pass any value
/// Future function
Future<void> initData({required String param1}) async {}
}
and navigate to the form screen we just need to add our method like the below:
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider(
create: (context) => FormController()..initData(param1:"my external param"),
builder: (context, child) => const CatalogScreen(),
),
)
);
In this line create:(context) => FormController()..initData(param1:"my external param),
we just create and initialize our state with initData().
+ passing the external parameters.
But as documentation said, this way is also quite less ideal. Because the external parameter will be static. To solve this issue, we can use ProxyProvider.
ProxyProvider
is a provider that combines multiple values from other providers into a new object and sends the result to Provider
.
That new object will then be updated whenever one of the provider we depend on gets updated.
If you read the example app from official Flutter documentation, it used this kind of provider. Read here: Simple app state management.
Honestly, I did not implement this provider in my project. But if you have a Notifier depending on another value, you may try this. For example:
Provider(create: (context) => CatalogModel()),
ChangeNotifierProxyProvider<CatalogModel, CartModel>(
create: (context) => CartModel(),
update: (context, catalog, cart) {
if (cart == null) throw ArgumentError.notNull('cart');
cart.catalog = catalog;
return cart;
},
),
Thank you for having the end 🙏. Feel free to leave any comment if you have anything to ask or to clarify 😃. And last, don't forget to clap 👏 if you like this article.