Chapters

Hide chapters

Flutter Apprentice

First Edition – Early Access 3 · Flutter 1.22.6 · Dart 2.10.5 · Android Studio 4.1.2

Section III: Navigating Between Screens

Section 3: 3 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence and State

Section 4: 7 chapters
Show chapters Hide chapters

Appendices

Section 6: 2 chapters
Show chapters Hide chapters

14. State Management
Written by Kevin D Moore

The main job of a UI is to represent state. Imagine, for example, you’re loading a list of recipes from the network. While the recipes are loading, you show a spinning widget. When the data loads, you swap the spinner with the list of loaded recipes. In this case, you move from a loading to a loaded state. Handling such state changes manually, without following a specific pattern, quickly leads to code that’s difficult to understand, update and maintain. One solution is to adopt a pattern that programmatically establishes how to track changes and how to broadcast details about states to the rest of your app. This is called state management.

To learn about state management and see how it works for yourself, you’ll continue working with the previous project. You can also start fresh by opening this chapter’s starter project. If you choose to do this, remember to click the Get dependencies button or execute flutter pub get from Terminal. You’ll also need to add your API Key and ID to lib/network/recipe_service.dart.

By the end of the chapter, you’ll know:

  • Why you need state management.
  • How to implement state management using Provider.
  • How to save the current list of bookmarks and ingredients.
  • How to create a repository.
  • How to create a mock service.
  • Different ways to manage state.

Architecture

When you write apps whose code gets larger and larger over time, you learn to appreciate the importance of separating code into manageable pieces. When files contain more than one class or classes combine multiple functionalities, it’s harder to fix bugs and add new features.

One way to handle this is to follow Clean Architecture principles by organizing your project so it’s easy to change and understand. You do this by separating your code into separate directories and classes, with each class handling just one task. You also use interfaces to define contracts that different classes can implement, allowing you to easily swap in different classes or reuse classes in other apps.

You should design your app with some or all of the components below:

Notice that the UI is separate from the business logic. It’s easy to start an app and put your database and business logic into your UI code — but what happens when you need to change the behavior of your app and that behavior is spread throughout your UI code? That makes it difficult to change and causes duplicate code that you might forget to update.

Communicating between these layers is important as well. How does one layer talk to the other? The easy way is to just create those classes when you need them. But this results in multiple instances of the same class, which causes problems coordinating calls.

For example, what if two classes each have their own database handler class and make conflicting calls to the database? Both Android and iOS use Dependency Injection or DI to create instances in one place and inject them into other classes that need them. This chapter will cover the Provider package, which does something similar.

Ultimately, the business logic layer should be in charge of deciding how to react to the user’s actions and how to delegate tasks like retrieving and saving data to other classes.

Why you need state management

First, what do the terms state and state management mean? State is when a widget is active and stores its data in memory. The Flutter framework handles some state, but as mentioned earlier, Flutter is declarative. That means it rebuilds a UI StatefulWidget from memory when the state or data changes or when another part of your app uses it.

State management is, as the name implies, how you manage the state of your widgets and app.

Two state types to consider are ephemeral state, also known as UI state and app state:

  • Use Ephemeral state when no other component in the widget tree needs to access a widget’s data. Examples include whether a TabBarView tab is selected or FloatingActionButton is pressed.
  • Use App state when other parts of your app need to access a widget’s state data. One example is an image that changes over time, like an icon for the current weather. Another is information that the user selects on one screen and which should then display on another screen, like when the user adds an item to a shopping cart.

Next, you’ll learn more about the different types of state and how they apply to your recipe app.

Widget state

In Chapter 4, “Understanding Widgets”, you saw the difference between stateless and stateful widgets. A stateless widget is drawn with the same state it had when it was created. A stateful widget preserves its state and uses it to (re)draw itself in the future.

Your current RecipeList screen has a card with the list of previous searches and a GridView with a list of recipes:

The left side shows some of the RecipeList widgets, while the right side shows the state objects that store the information each widget uses. An element tree stores both the widgets themselves and the states of all the stateful widgets in the RecipeList:

If the state of a widget updates, the state object also updates and the widget is redrawn with that updated state.

This kind of management handles state only for a specific widget. But what if you want to manage state for your whole app or share state between widgets and screens? You do this using application state.

Application state

In Flutter, a stateful widget can hold state, which its children can access, and pass data to another screen in its constructor. However, that complicates your code and you have to remember to pass data objects down the tree. Wouldn’t it be great if child widgets could easily access their parent data without having to pass in that data?

There are several different ways to achieve that, both with built-in widgets and with third-party packages. You’ll look at built-in widgets first.

Managing state in your app

Your app needs to save three things: the current list of My Recipes, the user’s bookmarks and the ingredients. In this chapter, you’ll use state management to save this information so other screens can use it.

At this point, you’ll only save this data in memory so when the user restarts the app, those selections won’t be available. Chapter 16, “Saving Data with SQLite”, will show how to save that data locally to a database for more permanent persistence.

These methods are still relevant for sharing data between screens. Here’s a general idea of how your classes will look:

Stateful widgets

StatefulWidget is one of the most basic ways of saving state, allowing the UI to change without rebuilding the widget. The RecipeList widget, for example, saves several fields for later usage, including the current search list and the start and end positions of search results for pagination.

When you create a stateful widget, you call createState(), which stores the state internally in Flutter to reuse when the parent needs to rebuild the widget tree. When the widget is rebuilt, Flutter reuses the existing state.

You use initstate() for one-time work, like initializing text controllers. Then you use setState() to change state, triggering a rebuild of the widget with the new state.

For example, in Chapter 10, “Shared Preferences”, you used setState() to set the selected tab. This tells the system to rebuild the UI to select a page. StatefulWidget is great for maintaining internal state, but not for state outside of the widget.

One way to achieve an architecture that allows sharing state between widgets is to adopt Inherited Widgets.

InheritedWidget

InheritedWidget is a built-in class that allows its child widgets to access its data. It’s the basis for a lot of other state management widgets. If you create a class that extends InheritedWidget and give it some data, any child widget can access that data by calling context.dependOnInheritedWidgetOfExactType<class>().

Wow, that’s quite a mouthful! As shown below, <class> represents the name of the class extending InheritedWidget.

You can then extract data from that widget. Since that’s such a long method name to call, the convention is to create an of() method. For example:

Note: updateShouldNotify() compares two recipes, which requires Recipe to implement equals. Otherwise, you need to compare each field.

Then a child widget, like the text field that displays the recipe title, can just use:

An advantage of using InheritedWidget is it’s a built-in widget so you don’t need to worry about using external packages.

A disadvantage of using InheritedWidget is that the value of a recipe can’t change unless you rebuild the whole widget tree because InheritedWidget is immutable. So, if you want to change the displayed recipe title, you’ll have to rebuild the whole RecipeWidget.

For a while, scoped_model (https://pub.dev/packages/scoped_model) was an interesting solution. It comes from the Fuchsia codebase and builds on top of InheritedWidget to separate UI and data, making the process easier than just using InheritedWidget.

However, since its version 1.0.0 release in November 2018, Google started recommending Provider as a better solution that provides similar functionalities to scoped_model and more. You’ll use Provider to implement state management in Recipe Finder.

Provider

Remi Rousselet designed Provider to wrap around InheritedWidget, simplifying it. Google had already created their own package to handle state management, but realized Provider was better. They now recommend using it, instead. Provider uses similar concepts as InheritedWidget and scoped_model.

Google even includes details about it in their state management docs (https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple#providerof).

In essence, Provider is a set of classes that simplifies a building state management solution on top of InheritedWidget.

Classes used by Provider

Provider has several commonly used classes that you’ll learn about in more detail: ChangeNotifierProvider, Consumer, FutureProvider, MultiProvider and StreamProvider.

One of the key classes, built in the Flutter SDK, is ChangeNotifier.

ChangeNotifier

ChangeNotifier is a class that adds and removes listeners, then notifies those listeners of any changes. You usually extend the class for models so you can send notifications when your model changes. When something in the model changes, you call notifyListeners() and whoever is listening can use the newly changed model to redraw a piece of UI, for example.

ChangeNotifierProvider

ChangeNotifierProvider is a widget that wraps a class, implementing ChangeNotifier and another widget. When changes are broadcast, the widget rebuilds its tree. The syntax looks like this:

Provider solves one very important problem, the constant recreation of objects when you call build. When a widget changes, you call build to rebuild that widget, and that can happen frequently.

But what happens if you create a model in that widget each time? That creates a new one! Any work that model did is lost the next time. By using create, Provider saves that model instead of re-creating it each time.

Consumer

Consumer is a widget that listens for changes in a class that implements ChangeNotifier, then rebuilds the widgets below itself when it finds any. When building your widget tree, try to put a Consumer as deep as possible in the UI hierarchy, so updates don’t recreate the whole widget tree.

If you only need access to the model and don’t need notifications when the data changes, use Provider.of, like this:

listen: false indicates you don’t want notifications for any updates. This parameter is required to use Provider.of() inside initState().

FutureProvider

FutureProvider works like other providers and uses the required create parameter that returns a Future.

A Future is handy when a value is not readily available but will be in the future. Examples include calls that request data from the internet or asynchronously read data from a database.

MultiProvider

What if you need more than one provider? You could nest them, but it’d get messy, making them hard to read and maintain.

Instead, use MultiProvider to create a linear widget tree and a single child:

StreamProvider

You’ll learn about streams in detail in the next chapter. For now, you just need to know that Provider also has a provider that’s specifically for streams and works the same way as FutureProvider. Stream providers are handy when data comes in via streams and values change over time like, for example, when you’re monitoring the connectivity of a device.

It’s time to use Provider to manage state in Recipe Finder. The next step is to add it to the project.

Using Provider

Open pubspec.yaml and add the following packages after logging:

provider: ^4.3.2+2
equatable: ^1.2.5

Provider contains all the classes mentioned above. Equatable helps with equality checks by providing equals() and toString() as well as hashcode. This allows you to check models for equality in maps.

Run Pub Get to install the new packages.

UI Models

In earlier chapters, you created models for the Recipe API. Here, you’ll create simple models to share data between screens.

In lib, create a new directory named data and within it create a new file named repository.dart. Leave that file empty for now.

In data, create a new directory named models. Within it create a new file named ingredient.dart and add the following class:

import 'package:equatable/equatable.dart';
// 1
class Ingredient extends Equatable {
  // 2
  final int id;
  int recipeId;
  final String name;
  final double weight;

  // 3
  Ingredient({this.id, this.recipeId, this.name, this.weight});

  // 4
  @override
  List<Object> get props => [recipeId, name, weight];
}

Here’s what’s happening in this code:

  1. The Ingredient class extends Equatable, to provide support for equality checks.
  2. Add the properties an ingredient needs. You don’t declare recipeId as final so you can change it later.
  3. Declare a constructor with all the fields.
  4. When equality checks are performed, Equatable uses the props value. Here, you provide the fields you want to use to check for equality.

The next step is to create a class to model a recipe.

Creating the recipe class

In models, create recipe.dart then add the following code:

import 'package:equatable/equatable.dart';
import 'ingredient.dart';

class Recipe extends Equatable {
  // 1
  int id;
  final String label;
  final String image;
  final String url;
  // 2
  List<Ingredient> ingredients;
  final double calories;
  final double totalWeight;
  final double totalTime;

  // 3
  Recipe(
      {this.id,
      this.label,
      this.image,
      this.url,
      this.calories,
      this.totalWeight,
      this.totalTime});

  // 4
  @override
  List<Object> get props =>
      [label, image, url, calories, totalWeight, totalTime];
}

The code above includes:

  1. Recipe properties for the recipe text: label, image and url. id is not final so you can update it.
  2. A list of ingredients that the recipe contains along with its calories, weight and time to cook.
  3. A constructor with all fields except ingredients, which you’ll add later.
  4. Equatable properties, which you’ll use for comparison.

Instead of importing the same files multiple times, you’ll use a single Dart file that exports all the files you need. Essentially, you’ll group multiple imports into a single one.

In models, create models.dart and add the following to export your two model files:

export 'recipe.dart';
export 'ingredient.dart';

Now, you can just import models.dart instead of having to import both recipe.dart and ingredient.dart every time you need them.

With the models in place, it’s now time to implement the conversion from data received via the network to model objects.

Convert data into models to display

Open lib/network/recipe_model.dart and import your new models.dart file:

import '../data/models/models.dart';

Then, go to the bottom of the file, beneath APIIngredients, and add a new method to convert a network ingredient model to a display ingredient model:

List<Ingredient> convertIngredients(List<APIIngredients> apiIngredients) {
  // 1
  final ingredients = List<Ingredient>();
  // 2
  apiIngredients.forEach((ingredient) {
    ingredients
        .add(Ingredient(name: ingredient.name, weight: ingredient.weight));
  });
  return ingredients;
}

In this code, you:

  1. Create a new list of ingredients to return.
  2. Convert each APIIngredient into an instance of Ingredient and add it to the list.

You’re now ready to create a repository to handle the creation, fetching and deletion of recipes.

Creating a repository

Next, you’ll create a repository interface to provide, add and delete recipes and ingredients.

Open data/models/repository.dart and import models.dart:

import 'models/models.dart';

Then, add this to define the repository interface:

abstract class Repository {
  // TODO: Add find methods

  // TODO: Add insert methods

  // TODO: Add delete methods

  // TODO: Add initializing and closing methods
}

Remember that Dart doesn’t have the keyword interface; instead, it uses abstract class. That means you need to add the methods that you want all repositories to implement. You’ll do that next.

Finding recipes and ingredients

Replace // TODO: Add find methods with the following to help find recipes and ingredients:

// 1
List<Recipe> findAllRecipes();

// 2
Recipe findRecipeById(int id);

// 3
List<Ingredient> findAllIngredients();

// 4
List<Ingredient> findRecipeIngredients(int recipeId);

In this code, you define interfaces to:

  1. Return all recipes in the repository.
  2. Find a specific recipe by its ID.
  3. Return all ingredients.
  4. Find all the ingredients for the given recipe ID.

Adding recipes and ingredients

Next, replace // TODO: Add insert methods to insert a new recipe and any ingredients.

// 5
int insertRecipe(Recipe recipe);

// 6
List<int> insertIngredients(List<Ingredient> ingredients);

Here, you declare methods to:

  1. Insert a new recipe.
  2. Add all the given ingredients.

Deleting unwanted recipes and ingredients

Then, replace // TODO: Add delete methods to include delete methods:

// 7
void deleteRecipe(Recipe recipe);

// 8
void deleteIngredient(Ingredient ingredient);

// 9
void deleteIngredients(List<Ingredient> ingredients);

// 10
void deleteRecipeIngredients(int recipeId);

In this code, you add methods to:

  1. Delete the given recipe.
  2. Delete the given ingredient.
  3. Delete all the given ingredients.
  4. Delete all the ingredients for the given recipe ID.

Initializing and closing the repository

Now, you’ll add two final methods. Replace // TODO: Add initializing and closing methods with:

// 11
Future init();
// 12
void close();

In this final bit of code, you:

  1. Allow the recipe to initialize. Databases might need to do some startup work.
  2. Close the repository.

Now that you’ve defined the interface, you need to create a concrete implementation that stores these items in memory.

Creating a memory repository

The memory repository is where you store the ingredients in memory.

In data, create a new file named memory_repository.dart and add these imports:

import 'dart:core';
import 'package:flutter/foundation.dart';
// 1
import 'repository.dart';
// 2
import 'models/models.dart';

To break this code down:

  1. repository.dart contains the interface definition.
  2. models.dart exports the Recipe and Ingredient class definitions.

Defining the memory repository

Now, define MemoryRepository:

Note: MemoryRepository will have red squiggles until you finish adding all the required methods.

// 3
class MemoryRepository extends Repository with ChangeNotifier {
  // 4
  @override
  Future init() {
    return Future.value(null);
  }

  @override
  void close() {}

  // 5
  final List<Recipe> _currentRecipes = List<Recipe>();
  // 6
  final List<Ingredient> _currentIngredients = List<Ingredient>();

  // TODO: Add find methods

  // TODO: Add insert methods

  // TODO: Add delete methods

}

Here’s what’s going on in this code:

  1. MemoryRepository extends Repository and uses Flutter’s ChangeNotifier to enable listeners and notify those listeners of any changes.
  2. Since this is a memory repository, you need methods to initialize and close it.
  3. You store your current list of recipes.
  4. Then you store your current list of ingredients.

Since this class just saves into memory, you store the recipes and ingredients using lists.

Now, you’re ready to add the methods you need to find, insert and delete recipe data.

Finding stored recipes and ingredients

Replace // TODO: Add find methods with these:

@override
List<Recipe> findAllRecipes() {
  // 7
  return _currentRecipes;
}

@override
Recipe findRecipeById(int id) {
  // 8
  return _currentRecipes.firstWhere((recipe) => recipe.id == id);
}

@override
List<Ingredient> findAllIngredients() {
  // 9
  return _currentIngredients;
}

@override
List<Ingredient> findRecipeIngredients(int recipeId) {
  // 10
  final recipe =
      _currentRecipes.firstWhere((recipe) => recipe.id == recipeId);
  // 11
  final recipeIngredients = _currentIngredients
      .where((ingredient) => ingredient.recipeId == recipe.id)
      .toList();
  return recipeIngredients;
}

This code:

  1. Returns your current RecipeList.
  2. Uses firstWhere to find a recipe with the given ID.
  3. Returns your current ingredient list.
  4. Finds a recipe with the given ID.
  5. Uses where to find all the ingredients with the given recipe ID.

These methods help find any recipe or set of ingredients you need to display on the screen.

Adding recipes and ingredient lists

Replace // TODO: Add insert methods with these, which let you add recipes and lists of ingredients:

@override
int insertRecipe(Recipe recipe) {
  // 12
  _currentRecipes.add(recipe);
  // 13
  insertIngredients(recipe.ingredients);
  // 14
  notifyListeners();
  // 15
  return 0;
}

@override
List<int> insertIngredients(List<Ingredient> ingredients) {
  // 16
  if (ingredients != null && ingredients.length != 0) {
    // 17
    _currentIngredients.addAll(ingredients);
    // 18
    notifyListeners();
  }
  // 19
  return List<int>();
}

In this code, you:

  1. Add the recipe to your list.
  2. Call the method to add all the recipe’s ingredients.
  3. Notify all listeners of the changes.
  4. Return the ID of the new recipe. Since you don’t need it, it’ll always return 0.
  5. Check to make sure there are some ingredients.
  6. Add all the ingredients to your list.
  7. Notify all listeners of the changes.
  8. Return the list of IDs added. An empty list for now.

Deleting recipes and ingredients

Replace // TODO: Add delete methods with these, to delete a recipe or ingredient:

@override
void deleteRecipe(Recipe recipe) {
  // 20
  _currentRecipes.remove(recipe);
  // 21
  deleteRecipeIngredients(recipe.id);
  // 22
  notifyListeners();
}

@override
void deleteIngredient(Ingredient ingredient) {
  // 23
  _currentIngredients.remove(ingredient);
}

@override
void deleteIngredients(List<Ingredient> ingredients) {
  // 24
  _currentIngredients
      .removeWhere((ingredient) => ingredients.contains(ingredient));
  notifyListeners();
}

@override
void deleteRecipeIngredients(int recipeId) {
  // 25
  _currentIngredients
    .removeWhere((ingredient) => ingredient.recipeId == recipeId);
  notifyListeners();
}

Here, you:

  1. Remove the recipe from your list.
  2. Delete all the ingredients for this recipe.
  3. Notify all listeners that the data has changed.
  4. Remove the ingredients from your list.
  5. Remove all ingredients that are in the passed-in list.
  6. Go through all ingredients and look for ingredients that have the given recipe ID, then remove them.

You now have a complete MemoryRepository class that can find, add and delete recipes and ingredients. You’ll use this repository throughout the app.

Using the repository via Provider

It’s time to use your newly created repository and Provider. Open main.dart and add these imports:

import 'package:provider/provider.dart';
import 'data/memory_repository.dart';

Now, replace the build() method inside MyApp with:

Widget build(BuildContext context) {
  // 1
  return ChangeNotifierProvider<MemoryRepository>(
    // 2
    lazy: false,
    // 3
    create: (_) => MemoryRepository(),
    // 4
    child: MaterialApp(
      title: 'Recipes',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MainScreen(),
    ));
}

In the code above, you:

  1. Use the ChangeNotifierProvider that has the type MemoryRepository.
  2. Set lazy to false, which creates the repository right away instead of waiting until you need it. This is useful when the repository has to do some background work to start up.
  3. Create your repository.
  4. Return MaterialApp as the child widget.

Note: If your code doesn’t automatically format when you save your changes, remember that you can always reformat it by going to the Code menu and choosing Reformat Code.

The code for the model is all in place. It’s now time to use it in the UI.

Using the repository for recipes

You’ll implement code to add a recipe to the Bookmark screen and ingredients to the Groceries screen. Open ui/recipes/recipe_details.dart and add the following imports:

import 'package:provider/provider.dart';
import '../../network/recipe_model.dart';
import '../../data/models/models.dart';
import 'package:recipes/data/memory_repository.dart';

This includes the Provider package, the models and the repository. To show a recipe’s details, you need to pass in the recipe you want to show.

Replace the line below // TODO: Replace with new constructor with:

final Recipe recipe;
const RecipeDetails({Key key, @required this.recipe}) : super(key: key);

Displaying the recipes’ details

You need to show the recipe’s image, label and calories on the Details page. The repository already stores all of your currently bookmarked recipes.

Still in ui/recipes/recipe_details.dart, add this as the first line of the build() method:

final repository = Provider.of<MemoryRepository>(context);

This uses Provider to retrieve the repository created in main.dart. You’ll use it to add the bookmark.

Note: If your recipe_details.dart file does not have the // TODO comments, take a look at the starter project for the next few steps.

Find the first // TODO 1 comment and replace the hard-coded imageUrl with:

imageUrl: recipe.image,

At // TODO 2, replace the text Chicken Vesuvio with:

recipe.label,

Replace the line below // TODO 3 with:

label: Text(getCalories(recipe.calories)),

getCalories() is from recipe_model.dart.

Next, you’ll enable the user to tap the Bookmark button to add the recipe to the list of bookmarks.

Bookmarking recipes

The first step is to insert the recipe into the repository.

Within ui/recipes/recipe_details.dart, right after // TODO 4, add:

repository.insertRecipe(recipe);

This adds the recipe to your repository. To receive that recipe, you need to update recipe_list.dart to send the recipe to the details page.

Open ui/recipes/recipe_list.dart and add the models import:

import '../../data/models/models.dart';

Then go to _buildRecipeCard() and change return const RecipeDetails(); to:

final detailRecipe = Recipe(
    label: recipe.label,
    image: recipe.image,
    url: recipe.url,
    calories: recipe.calories,
    totalTime: recipe.totalTime,
    totalWeight: recipe.totalWeight);

detailRecipe.ingredients = convertIngredients(recipe.ingredients);
return RecipeDetails(recipe: detailRecipe);

This creates a new Recipe from the network recipe.

Now, build and run the app. Enter chicken in the search box and tap the magnifying glass to perform the search. You’ll see something like this:

Select a recipe to go to the details page:

Click the Bookmark button and the details page will disappear. Now, select the Bookmarks tab. At this point, you’ll see a blank screen — you haven’t implemented the Bookmarks screen yet. You’ll add that functionality next.

Implementing the Bookmarks screen

In ui/myrecipes, open my_recipes_list.dart and add the following imports:

import 'package:provider/provider.dart';
import '../../data/models/recipe.dart';
import '../../data/memory_repository.dart';

This includes Provider to retrieve the repository as well as the Recipe class.

On the Bookmarks page, the user can delete a bookmarked recipe by swiping left or right and selecting the delete icon. To implement this, add deleteRecipe() at the bottom of the _MyRecipesListState class:

void deleteRecipe(MemoryRepository repository, Recipe recipe) async {
  // 1
  repository.deleteRecipeIngredients(recipe.id);
  // 2
  repository.deleteRecipe(recipe);
  // 3
  setState(() {});
}

In this code, you use:

  1. The repository to delete any recipe ingredients.
  2. The repository to delete the recipe.
  3. setState() to redraw the view.

Still in _MyRecipesListState, replace the line below // TODO 1 with:

List<Recipe> recipes;

At // TODO 2, remove the whole definition of initState() because you’ll fill the list from the repository.

Replace // TODO 3 with the following:

return Consumer<MemoryRepository>(builder: (context, repository, child) {
  recipes = repository.findAllRecipes() ?? List();

This creates Consumer, which receives the repository. Remember that Consumer is a widget that can receive a class from a parent Provider.

The method findAllRecipes() will return either all the current recipes or an empty list.

At // TODO 4, add:

final recipe = recipes[index];

ListView.builder returns the current index, retrieving the recipe at that index.

Now, replace all the hard-coded values:

At // TODO 5, replace imageUrl with:

imageUrl: recipe.image,

At // TODO 6, replace title with:

title: Text(recipe.label),

At // TODO 7, replace the onTap() declaration with:

onTap: () => deleteRecipe(
    repository,
    recipe)),

This calls deleteRecipe(), which you’ll define later.

At // TODO 8, replace the other onTap() with:

onTap: () => deleteRecipe(
	  repository,
    recipe)),

Finally, replace // TODO 9 with:

  },
);

Build and run the app to make sure the recipe you bookmarked earlier shows up now. You’ll see:

You’re almost done, but the Groceries view is currently blank. Your next step is to add the functionality to show the ingredients of a bookmarked recipe.

Implementing the Groceries screen

Open ui/shopping/shopping_list.dart and add the following:

import 'package:provider/provider.dart';
import '../../data/memory_repository.dart';

Here you import Provider, the ingredient model and the repository.

Remove the line below TODO 1.

Replace // TODO 2 with:

return Consumer<MemoryRepository>(builder: (context, repository, child) {
  final ingredients = repository.findAllIngredients() ?? List();

This adds a Consumer widget to display the current ingredients.

Replace the line below // TODO 3 with:

title: Text(ingredients[index].name),

This will display the ingredient’s name property.

At // TODO 4, add:

  },
);

This closes the Consumer widget.

If your app is running, use hot reload. Otherwise, run your app, search for chicken again and bookmark one result.

Now, go to the Groceries tab to see the ingredients of the recipe you bookmarked. You’ll see something like this:

Congratulations, you made it! You now have an app where state changes can be monitored and notified across different screens, thanks to the infrastructure of Provider.

But there’s one more thing to think about: Hitting the real web server with a request every time you want to try a code change isn’t a good idea. It’s time-consuming and doesn’t give you control over the returned data. Your next step is to build a mock service that returns specific responses that imitate the real API.

Using a mock service

You’ll add an alternate way to retrieve data. This is handy because:

  • The Edamam site limits the number of queries you can make for the developer account.
  • It’s good practice to have the server return a mocked version of the data, especially for testing.

In assets, there are two recipe JSON files. You’ll create a mock service provider that randomly returns one of those files.

If you get errors when you retrieve data, you can just swap out the current repository with the mock service.

Start by creating a new directory under lib named mock_service. Next, create mock_service.dart in that new directory.

Add the imports:

import 'dart:convert';
import 'dart:math';

// 1
import 'package:chopper/chopper.dart';
// 2
import 'package:flutter/services.dart' show rootBundle;
import '../network/model_response.dart';
import '../network/recipe_model.dart';

Here’s what you’re doing:

  1. You import chopper to create instances of Response.
  2. show means that you want a specific class or classes to be visible in your app. In this case, you want rootBundle to be visible for loading JSON files.

Note: You can hide classes by using hide.

Now, add MockService:

class MockService {
  // 1
  APIRecipeQuery _currentRecipes1;
  APIRecipeQuery _currentRecipes2;
  // 2
  Random nextRecipe = Random();

  // TODO 1: Add create and load methods

  // TODO 2: Add query method

}

Here’s what this code does:

  1. Use _currentRecipes1 and _currentRecipes2 to store the results loaded from the two JSON files.
  2. nextRecipe is an instance of Random that creates a number between 0 and 1.

Next, you’ll load recipes from the JSON files.

Implementing methods to create and load recipes

Now, replace TODO 1 with:

// 3
void create() {
  loadRecipes();
}

void loadRecipes() async {
  // 4
  var jsonString = await rootBundle.loadString('assets/recipes1.json');
  // 5
  _currentRecipes1 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
  jsonString = await rootBundle.loadString('assets/recipes2.json');
  _currentRecipes2 = APIRecipeQuery.fromJson(jsonDecode(jsonString));
}

To break down this code:

  1. The create() method, which Provider will call, just calls loadRecipes().
  2. rootBundle loads the JSON file as a string.
  3. jsonDecode() creates a map then converts it to APIRecipeQuery.

Next, replace TODO 2 with the following:

Future<Response<Result<APIRecipeQuery>>> queryRecipes(
  String query, int from, int to) {
    // 6
    switch(nextRecipe.nextInt(2)) {
      case 0:
        // 7
        return Future.value(
            Response(null, Success<APIRecipeQuery>(_currentRecipes1)));
      case 1:
        return Future.value(
            Response(null, Success<APIRecipeQuery>(_currentRecipes2)));
      default:
        return Future.value(
            Response(null, Success<APIRecipeQuery>(_currentRecipes1)));
    }
  }

Here, you:

  1. Use your random field to pick a random number from 0 to 1.
  2. Wrap your APIRecipeQuery result in Success, Response and Future.

You’ll notice that this looks like the method from RecipeService. That’s because the mock service should look the same.

That’s all for mocking. Now you’ll use it in the app, instead of the real service.

Using the mock service

Add MockService to main.dart:

import 'mock_service/mock_service.dart';

Currently, build() is using ChangeNotifierProvider. Now, you need to use multiple providers so it can also use MockService. MultiProvider will accomplish this.

Replace the whole build() method with this:

Widget build(BuildContext context) {
  return MultiProvider(
    // 1
    providers: [
      // 2
      ChangeNotifierProvider<MemoryRepository>(
        lazy: false,
        create: (_) => MemoryRepository(),
      ),
      // 3
      FutureProvider(
        // 4
        create: (_) async {
          final service = MockService();
          // 5
          service.create();
          return service;
        },
        lazy: false,
      ),
    ],
    // 6
    child: MaterialApp(
      title: 'Recipes',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MainScreen(),
    ));
}

Here’s what’s going on in the code above:

  1. MultiProvider uses the providers property to define multiple providers.
  2. The first provider is your existing ChangeNotifierProvider.
  3. You add a new provider, FutureProvider, which returns a Future of a Provider.
  4. Make create() asynchronous. You don’t need it for this service but you’ll use it later.
  5. Call create() to load the JSON files.
  6. The only child is a MaterialApp, like before.

Now, you’re ready to load and display mocked recipes.

Loading mocked recipes

Open ui/recipes/recipe_list.dart and add the MockService and Provider imports:

import '../../mock_service/mock_service.dart';
import 'package:provider/provider.dart';

In _buildRecipeLoader(), change the RecipeService.create() to:

Provider.of<MockService>(context)

It should now look like this:

future: Provider.of<MockService>(context).queryRecipes(
    searchTextController.text.trim(),
    currentStartPosition,
    currentEndPosition),

Hot reload the app and search for any term in the Recipes tab. Notice how, no matter what you type, you only get chicken or pasta recipes. That’s because MockService only provides those two results. In the future, it will be easier to test specific changes or add more mocked data.

Congratulations, you now have a service that works even if you don’t have an account or your network isn’t working. You can even use MockService for testing. The advantage is that you know what results you’ll get because data is stored in static JSON files.

Amazing work! There was a lot in this chapter to learn, but it’s important work. State management is a key concept for Flutter development.

Is Provider the only option for state management? No. Brace yourself for a quick tour of alternative libraries.

Other libraries

There are other packages that help with state management and provide even more flexibility when managing state in your app. While Provider features classes for widgets lower in the widget tree, other packages provide more generic state management solutions for the whole app, often enabling a unidirectional data flow architecture.

Such libraries include Redux, BLoC, Mobx and Riverpod. Here’s a quick overview of each.

Redux

If you come from web or React development, you might be familiar with Redux, which uses concepts such as actions, reducers, views and store. The flow looks like this:

Actions, like clicks on the UI or events from network operations, are sent to reducers, which turn them into a state. That state is saved in a store, which notifies listeners, like views and components, about changes.

The nice thing about the Redux architecture is that a view can simply send actions and wait for updates from the store.

To use Redux in Flutter, you need two packages: redux and flutter_redux.

For React developers migrating to Flutter, an advantage of Redux is that it’s already familiar. If you are not familiar with it, it might take a bit to learn it.

BLoC

BLoC stands for Business Logic Component. It’s designed to separate UI code from the data layer and business logic, helping you create reusable code that’s easy to test. Think of it as a stream of events: some widgets submit events and other widgets respond to them. BLoC sits in the middle and directs the conversation, leveraging the power of streams.

It’s quite popular in the Flutter Community and very well documented.

Mobx

Mobx comes to Dart from the web world. It uses the following concepts:

  • Observables: Hold the state.
  • Actions: Mutate the state.
  • Reactions: React to the change in observables.

Mobx comes with annotations that help you write your code and make it simpler.

One advantage is that Mobx allows you to wrap any data in an observable. It’s relatively easy to learn and requires smaller generated code files than BLoC does.

Riverpod

Provider’s author, Remi Rousselet, wrote Riverpod to address some of Provider’s weaknesses. In fact, Riverpod is an anagram of Provider! Rousselet wanted to solve the following problems:

  1. Remove the dependency on Flutter to make it usable with pure Dart code.
  2. Be compile safe. Have the compiler catch errors that occur with Provider.
  3. Have more features.
  4. Be more flexible.

Riverpod is pretty new and it looks like a promising state management package to use in the future. It’s still a bit experimental and not fully stable at the time of writing.

Key points

  • State management is key to Flutter development.
  • Provider is a great package that helps with state management.
  • Other packages for handling application state include Redux, Bloc, Mobx and Riverpod.
  • Repositories are a pattern for providing data.
  • By providing an Interface for the repository, you can switch between different repositories. For example, you can switch between real and mocked repositories.
  • Mock services are a way to provide dummy data.

Where to go from here?

If you want to learn more about:

  1. State management, go to https://flutter.dev/docs/development/data-and-backend/state-mgmt/intro.
  2. For Flutter Redux go to https://pub.dev/packages/flutter_redux.
  3. For Bloc, go to https://bloclibrary.dev/#/.
  4. For Mobx, go to https://github.com/mobxjs/mobx.dart.
  5. For Riverpod, go to https://riverpod.dev/.
  6. For Clean Architecture, go to https://pusher.com/tutorials/clean-architecture-introduction.

In the next chapter you’ll learn all about streams, to handle data that can be sent and received continuously. See you there!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.