Flutter Advanced - Using Multiple Providers

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 CategoryProviderare 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 if RecipeList() or CategoryList() 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. The builder takes 3 parameters to be exact, i.e, BuildContext, something of type dynamic, and then a Widget.

  • 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 write Consumer<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 the BuildContext which we provide it using the available context 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 named categoryData.

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 the Consumer, i.e, widget which doesn't need the provider data but is still a part of the widget that depends on the provider, then the Consumer 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 or Grid 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 previous ChangeNotifierProvider.

  • 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 this value() constructor approach if you are attaching your providers in the main.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 this ChangeNotifierProvider 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 the main.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 👨🏽‍💻👩‍💻!