Chapters

Hide chapters

Flutter Apprentice

First Edition · Flutter 2.2.0 · Dart 2.13.0 · Android Studio 4.2.1

Section IV: Networking, Persistence and State

Section 4: 7 chapters
Show chapters Hide chapters

Appendices

Section 6: 2 chapters
Show chapters Hide chapters

13. State Management
Written by Kevin D Moore

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

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.

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?

Managing state in your app

Your app needs to save three things: the list to show in the Recipes screen, 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.

Stateful widgets

StatefulWidget is one of the most basic ways of saving state. 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.

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>().

class RecipeWidget extends InheritedWidget {
  final Recipe recipe;
  RecipeWidget({this.recipe, Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(RecipeWidget oldWidget) => recipe != oldWidget.recipe;

  static RecipeWidget of(BuildContext context) => context.inheritFromWidgetOfExactType(RecipeWidget);
}
RecipeWidget recipeWidget = RecipeWidget.of(context);
print(recipeWidget.recipe.label);

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.

Classes used by Provider

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

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:

ChangeNotifierProvider(
    create: (context) => MyModel(),
    child: <widget>,
);

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.

Consumer<MyModel>(
  builder: (context, model, child) {
    return Text('Hello ${model.value}');
  }
);
Provider.of<MyModel>(context, listen: false).<method name>

FutureProvider

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

FutureProvider(
  create: (context) => createFuture(),
  child: <widget>,
);

Future<MyModel> createFuture() async {
  return Future.value(MyModel());
}

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.

Provider<MyModel>(
  create: (_) => Something(),
  child: Provider<MyDatabase>(
   create: (_) => SomethingMore()
       child: <widget>
    ),
);
MultiProvider(
        providers: [
          Provider<MyModel>(create: (_) => Something()),
          Provider<MyDatabase>(create: (_) => SomethingMore()),
        ],
child: <widget>
);

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.

Using Provider

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

provider: ^4.3.3
equatable: ^1.2.6

UI Models

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

import 'package:equatable/equatable.dart';
// 1
class Ingredient extends Equatable {
  // 2
  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];
}

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];
}
export 'recipe.dart';
export 'ingredient.dart';

Convert data into models to display

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

import '../data/models/models.dart';
List<Ingredient> convertIngredients(List<APIIngredients> apiIngredients) {
  // 1
 final ingredients = <Ingredient>[];
  // 2
  apiIngredients.forEach((ingredient) {
    ingredients
        .add(Ingredient(name: ingredient.name, weight: ingredient.weight));
  });
  return ingredients;
}

Creating a repository

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

import 'models/models.dart';

abstract class Repository {
  // TODO: Add find methods

  // TODO: Add insert methods

  // TODO: Add delete methods

  // TODO: Add initializing and closing methods
}

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);

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);

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);

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();

Creating a memory repository

The memory repository is where you store the ingredients in memory. This is a temporary solution, as they are lost each time you restart the app.

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

Defining the memory repository

Now, define MemoryRepository by adding the following:

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

  @override
  void close() {}

  // 5
  final List<Recipe> _currentRecipes = <Recipe>[];
  // 6
  final List<Ingredient> _currentIngredients = <Ingredient>[];

  // TODO: Add find methods

  // TODO: Add insert methods

  // TODO: Add delete methods

}

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;
}

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 <int>[];
}

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();
}

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

Using the repository for recipes

You’ll implement code to add a recipe to the Bookmarks 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 '../../data/memory_repository.dart';
final Recipe recipe;
const RecipeDetails({Key key, 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.

final repository = Provider.of<MemoryRepository>(context);
imageUrl: recipe.image,
recipe.label,
label: Text(getCalories(recipe.calories)),

Bookmarking recipes

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

repository.insertRecipe(recipe);
import '../../data/models/models.dart';
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);

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';
void deleteRecipe(MemoryRepository repository, Recipe recipe) async {
  // 1
  repository.deleteRecipeIngredients(recipe.id);
  // 2
  repository.deleteRecipe(recipe);
  // 3
  setState(() {});
}
List<Recipe> recipes;
return Consumer<MemoryRepository>(builder: (context, repository, child) {
  recipes = repository.findAllRecipes() ?? [];
final recipe = recipes[index];
imageUrl: recipe.image,
title: Text(recipe.label),
onTap: () => deleteRecipe(
    repository,
    recipe)),
onTap: () => deleteRecipe(
	  repository,
    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';
return Consumer<MemoryRepository>(builder: (context, repository, child) {
  final ingredients = repository.findAllIngredients() ?? [];
title: Text(ingredients[index].name),
  },
);

Using a mock service

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

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';
class MockService {
  // 1
  APIRecipeQuery _currentRecipes1;
  APIRecipeQuery _currentRecipes2;
  // 2
  Random nextRecipe = Random();

  // TODO 1: Add create and load methods

  // TODO 2: Add query method

}

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));
}

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)));
    }
  }

Using the mock service

Add MockService to main.dart:

import 'mock_service/mock_service.dart';
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(
        brightness: Brightness.light,
        primaryColor: Colors.white,
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const MainScreen(),
    ));
}

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';
Provider.of<MockService>(context)
future: Provider.of<MockService>(context).queryRecipes(
    searchTextController.text.trim(),
    currentStartPosition,
    currentEndPosition),

Other state management 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.

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:

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.

MobX

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

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:

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:

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now