Flutter Advanced - Using Multiple Providers
A guide to using multiple providers and different ways of consuming them at different places in your app!
In the previous article this series, we had a closer look at implementing providers to handle state management. But what if we have to implement multiple providers in our app and listen to them in different widgets at different places and in different ways.
In this article, we will see how we can implement multiple providers in our app, listen to them in different widgets at different places in the app. So without further ado let's get started.
Understanding the Problem
Consider an eCommerce application that you are building. It will have multiple types of data such as products, cart, categories, user data, etc. You can handle all the data inside a single provider and hook it to your application but the single provider handling all that data will be too cluttered, and harder to debug and maintain.
This is the reason why we would like to split all our app data into multiple providers. The problem with this approach would be to connect all these providers with different widgets at different places in our app. This is what we are going to have a look at in this article.
The example we will see here
Here we will have two providers namely RecipeProvider and CategoryProvider where we will handle all the recipes and categories. You will see all the RecipeProvider related setup and code in the previous article in the series!
So let's begin setting up another provider, i.e, CategoryProvider in which we will store all the categories data.
Setting up the Category Provider
The REST API endpoint
If you want to try the endpoint for yourself here you go, it is a GET
request,
https://bakeology-alpha-stage.herokuapp.com/user/categories
The JSON data that you would expect will look like this,
{
"message": "Categories Fetched Successfully.",
"categories": [
{
"recipes": [
"5fa77fc6bc146402c599efb8",
"5ff70302369bf30004e1d802"
],
"_id": "5fa56a86240c6d54b32f5663",
"title": "Baking",
"colorA": "#F7FBD5",
"colorB": "#43C6AC",
"iconImageUrl": "images/2020-11-06T15:23:50.518Z-cupcake.png",
"__v": 4
},
{
"recipes": [],
"_id": "5fa56bd4240c6d54b32f5665",
"title": "Salads",
"colorA": "#F4D0D4",
"colorB": "#EE9CA7",
"iconImageUrl": "images/2020-11-06T15:29:24.207Z-food.png",
"__v": 0
},
{
"recipes": [],
"_id": "5fa56cdf240c6d54b32f5666",
"title": "Appetizers",
"colorA": "#86fde8",
"colorB": "#acb6e5",
"iconImageUrl": "images/2020-11-06T15:33:51.172Z-appetizers.png",
"__v": 0
},
{
"recipes": [
"5fa77fc6bc146402c599efb8"
],
"_id": "5fa56e3c240c6d54b32f5667",
"title": "Breakfast",
"colorA": "#E4E5E6",
"colorB": "#00416A",
"iconImageUrl": "images/2020-11-06T15:39:40.357Z-breakfast.png",
"__v": 3
},
{
"recipes": [],
"_id": "5fa77d81bc146402c599efb5",
"title": "Lunch",
"colorA": "#ffa751",
"colorB": "#ffe259",
"iconImageUrl": "images/2020-11-08T05:09:21.830Z-lunch.png",
"__v": 0
},
{
"recipes": [
"5fa77fc6bc146402c599efb8",
"5ff70302369bf30004e1d802"
],
"_id": "5fa77dc3bc146402c599efb6",
"title": "Dinner",
"colorA": "#2c3e50",
"colorB": "#bdc3c7",
"iconImageUrl": "images/2020-11-08T05:10:27.294Z-dinner.png",
"__v": 2
}
],
"totalItems": 6
}
Creating Category Model
We will follow the MVC design pattern here so we will create a category.dart
file in the models
folder. Our category.dart
file looks like this,
import 'package:flutter/foundation.dart';
class Category {
final String id;
final String title;
final String colorA;
final String colorB;
final String iconImageUrl;
final List<dynamic> recipes;
Category(
{@required this.id,
@required this.title,
@required this.colorA,
@required this.colorB,
@required this.iconImageUrl,
@required this.recipes});
}
We will use this category model to map all the data coming from the backend to our provider.
Creating the CategoryProvider
I am naming the provider for the categories as category_provider.dart
. We will create this provider in the providers
folder. The code looks like this,
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../models/category.dart';
class CategoryProvider with ChangeNotifier {
List<Category> _categories = [];
List<Category> get categories {
return _categories;
}
Future<Category> fetchAndSetCategories() async {
var url = 'https://bakeology-alpha-stage.herokuapp.com/user/categories';
try {
final response = await http.get(url);
final extractedData = json.decode(response.body) as Map<String, dynamic>;
if (extractedData == null) {
return null;
}
final List<Category> loadedCategories = [];
extractedData["categories"].forEach((categoryData) {
loadedCategories.add(
Category(
id: categoryData["_id"],
title: categoryData["title"],
colorA: categoryData["colorA"],
colorB: categoryData["colorB"],
iconImageUrl: categoryData["iconImageUrl"],
recipes: categoryData["recipes"],
),
);
});
_categories = loadedCategories;
notifyListeners();
} catch (error) {
print(error);
throw error;
}
}
}
The category provider looks the same as the recipe provider. Here we map all the categories-related data that we get from the backend into the provider. To know more details on how the data is mapped and about ChangeNotifier
, notifyListeners()
refer to the previous post in the series.
Taking into account the previous article we have two providers, i.e, RecipeProvider and CategoryProvider. Now let's see how we can hook up these two providers in our main.dart
file.
Linking the Providers at the top of our Widget Tree
We will link all our providers to the highest possible point in the widget tree. We will do this in the main.dart
file. Hence our main.dart
file looks like this,
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import './providers/recipe_provider.dart';
import './providers/category_provider.dart';
import './screens/home_screen.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (ctx) => RecipeProvider(),
),
ChangeNotifierProvider(
create: (ctx) => CategoryProvider(),
),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Multi Provider Demo App',
theme: ThemeData(
primarySwatch: Colors.blueGrey,
accentColor: Colors.blueGrey[300],
fontFamily: 'Poppins',
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: HomeScreen(),
),
);
}
}
Let's see how we are attaching multiple providers at the highest possible point in the widget tree of interested widgets.
❖ MultiProvider
As the name suggests MultiProvider
is used to attach multiple providers to the app at once. As you can see in the above code that both RecipeProvider
and CategoryProvider
are provided to our app. It is not limited to providing just two providers but as many as required.
Our main HomeScreen
widget
import 'package:flutter/material.dart';
import '../widgets/recipe_list.dart';
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar:AppBar(
title:Text('All Recipes'),
centerTitle: true,
),
body: Column(
children: [
SizedBox(height: 20),
CategoryList(),
SizedBox(height: 20),
RecipeList(),
],
),
);
}
}
Here, you can see that we have RecipeList()
and CategoryList()
widgets in our HomeScreen
.
The
HomeScreen
itself is a stateless widget and will not rebuild even ifRecipeList()
orCategoryList()
widgets will rebuild because of the associated provider data change.
Different ways of Listening to the Provided data
Listening to the attached provider using Provider.of<...>(context)
In the RecipeList
widget, we will use Provider.of<..>(context)
to connect our widget to the provider. So our RecipeList
widget recipe_list.dart
will look like this,
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import './recipe_list_item.dart';
import '../providers/recipe_provider.dart';
class RecipeList extends StatefulWidget {
@override
_RecipeListState createState() => _RecipeListState();
}
class _RecipeListState extends State<RecipeList> {
bool _isLoading = false;
@override
void initState() {
setState(() {
_isLoading = true;
});
Provider.of<RecipeProvider>(context, listen: false)
.fetchAndSetRecipes()
.then((_) {
setState(() {
_isLoading = false;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
final recipeData = Provider.of<RecipeProvider>(context);
final fetchedRecipes = recipeData.recipes;
return _isLoading
? Center(
child: CircularProgressIndicator(),
)
: Container(
child: ListView.builder(
shrinkWrap: true,
itemCount: fetchedRecipes.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: RecipeListItem(
recipeId: fetchedRecipes[i].id,
recipeTitle: fetchedRecipes[i].title,
recipeImageUrl: fetchedRecipes[i].imageUrl,
chefName: fetchedRecipes[i].chefName,
chefImageUrl: fetchedRecipes[i].chefImageUrl,
isVegetarian: fetchedRecipes[i].isVegetarian,
duration: fetchedRecipes[i].duration,
),
),
),
);
}
}
This is pretty much the same code that we wrote and discussed in the previous article. Now let's have a look at a different approach of consuming the data from a provider.
Using Consumer
to connect data to our widget
This widget is the same as we discussed in the previous article. Now let's have a look at the CategoryList
widget. Our category_list.dart
file looks like this,
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import './category_list_item.dart';
import '../providers/category_provider.dart';
class CategoryList extends StatefulWidget {
@override
_CategoryListState createState() => _CategoryListState();
}
class _CategoryListState extends State<CategoryList> {
bool _isLoading = false;
@override
void initState() {
setState(() {
_isLoading = true;
});
Provider.of<CategoryProvider>(context, listen: false)
.fetchAndSetCategories()
.then((_) {
setState(() {
_isLoading = false;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return _isLoading
? Center(
child: CircularProgressIndicator(),
),
)
: Consumer<CategoryProvider>(
builder: (context, categoryData, _) => Container(
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: categoryData.categories.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: CategoryListItem(
categoryId: categoryData.categories[index].id,
colorA: categoryData.categories[index].colorA,
colorB: categoryData.categories[index].colorB,
iconImageUrl: categoryData.categories[index].iconImageUrl,
title: categoryData.categories[index].title,
),
),
),
),
);
}
}
This widget is also similar to our RecipeList
widget. It also has its own internal state to show whether the process of fetching data has finished or is in progress by showing a CircularProgressIndicator()
.
❖ The main difference here is that we are not using Provider.of(context)...
anymore, instead, we are using Consumer
. Consumer wraps the part of the widget tree which are interested in an update whenever the data changes.
How Consumer
works?
Consumer takes a
builder
instead of just a child. Thebuilder
takes 3 parameters to be exact, i.e,BuildContext
, something of typedynamic
, and then aWidget
.Consumer is a generic type so we must mention which type of data we want to consume. In our case we are consuming the data from
CategoryProvider
hence we writeConsumer<CategoryProvider>(...)
.You can compare this to
Provider.of<...>(context)
as both establish an active listener to the provider and triggers the build method when the data changes.The
builder
takes theBuildContext
which we provide it using the availablecontext
inside our widget.It also receives the instance of the data. Like in the case above, we get the latest snapshot of the data from the
CategoryProvider
which we namedcategoryData
.
As the third argument, it also takes a parameter of type
Widget
. If we pass a certain part of our widget tree as a third argument to theConsumer
, i.e, widget which doesn't need the provider data but is still a part of the widget that depends on the provider, then theConsumer
will prevent those widgets from rebuilding thus increasing the efficiency even more.
Hence, both Provider.of<...>(context)
and Consumer<...>
are the same, except the difference being that in Consumer
you can even optimize the build method further by providing those child widgets as a child to the Consumer
. Therefore, rebuilding the only widget necessary!
Different ways of attaching the Provider Data
In combination with Provider.of<...>(context)
and Consumer<...>
to provide data to our widgets we also have other syntaxes for attaching Providers at the highest possible point in our widget tree apart from ChangeNotifierProvider
.
❖ ChangeNotifierProvider.value()
Apart from ChangeNotifierProvider
we can also use ChangeNotifierProvider.value()
. The value()
constructor has an important use case.
In case you are attaching your Provider somewhere below the widget tree and providing the data to a single
List
orGrid
and at some point in time you will navigate to some other part of the app then Flutter will recycle the widgets that you are attaching your provider to which is not the case in the previousChangeNotifierProvider
.When using the
value()
constructor you actually make sure that the Provider works even if data changes for the widget. If you had a builder function that would not work correctly and if the widgets are not recycled, it may lead to memory leaks at some point later in time.This happens because the widgets are recycled by Flutter but the data that is attached to the widget changes. In this case the
value()
constructor, we make sure that the provider works even if the data changes for the widget and will work properly because it is no longer tied to the widget.✅This approach makes sure that the provider is tied to its data rather than the widget. Also, if we use this the provider can keep up with the frequent change in data and is thus recommended to be used in cases where the data changes frequently. In the case of a list/grid where we paginate the data from the backend and receive it in batches, this is the recommended approach.
As a sidenote, use the
value()
constructor if you are attaching your provider somewhere below in the widget tree. Don't use thisvalue()
constructor approach if you are attaching your providers in themain.dart
file.It is not that your provider won't work but it is the best practice that has emerged over the years.
❖ChangeNotifierProvider
✅If you are attaching your providers at the top of your widget tree, i.e,
main.dart
, then using thisChangeNotifierProvider
is fine and is the recommended approach!This approach makes sure that the provider is tied to the widget.
Don't use the
value()
constructor if you are attaching your provider to themain.dart
file, i.e, to the root of your application!
Conclusion
These are the approaches you can have to provide the data from the provider to the different parts of your application by keeping the above points in mind and according to your usage scenario.
I hope you had some deep insights on how to attach data and provide data to the different parts of your application using different syntaxes offered by the provider
package. That was all for this article. If you had a fun time reading this a smash on the emojis would be awesome! Till then, adios and Happy Coding 👨🏽💻👩💻!