Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

13. Managing State
Written by Kevin D Moore

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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

Note: 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 to lib/network/spoonacular_service.dart.

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

  • Why you need state management.
  • How to implement state management using Riverpod.
  • How to save the current list of bookmarks and ingredients.
  • What a repository is.
  • Different ways to manage state.

Architecture

When you write apps and the amount of 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 when 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 directories and classes, each 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:

UI Databases Network Business Logic

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 your app’s behavior and that behavior is spread throughout your UI code? That makes it difficult to change and causes duplicate code 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 just to create those classes when you need them. However, 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 Riverpod package for DI and state management.

Note: Don’t get confused with Dependency Injection and State Management. They are two different things. Dependency Injection is a way to inject or provide the dependencies needed inside the app, and State Management is a way to manage the app’s state.

Ultimately, the business logic layer should decide how to react to the user’s actions and 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 the UI 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 when there’s any change in the widget’s state.

CocizuTokc Fozp Nzampaim KuqudDumoWamlaq Hoxn<Zawufe> wjulueefNeenszum Cijqovd JebogiBudl Fsidu

Xozn WsibJiop FogsJuca Lepv Osekobh YtoyNeov Emarotq Joyz Ruqo Opoyocw Qadbuw Qvea Avitekq Kwui

Application State

In Flutter, a StatefulWidget can hold state. Its children can access it, and even pass (pieces of) it to other screens. 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

The Recipes Finder app needs to save four things: the currently selected screen, 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.

Rakxazw Gotdol Yemi Raloteluyd Gixet Gceguja Gocvatf Domu

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 allowing 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 gives it some data, any child widget can access it by calling context.dependOnInheritedWidgetOfExactType<class>().

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

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

  static RecipeWidget of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<RecipeWidget>()!;

}

RecipeWidget recipeWidget = RecipeWidget.of(context);
print(recipeWidget.recipe.label);

Provider

Remi Rousselet designed Provider to build state management functionalities on top of InheritedWidget.

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:

Keypoints of Riverpod

Before you start using Riverpod, you need to understand some of its key points.

Types of Providers

There are several different types of providers:

Provider

Provider is the most basic class that provides a value to other classes. You create a global variable (so that anyone can find it) that points to a function that returns an instance. You create a provider like this:

final myProvider = Provider((ref) {
  return MyValue();
});

StateProvider

StateProvider is a simplified version of StateNotifierProvider. It allows you to modify simple variables. This includes strings, Booleans, numbers or lists of items. You can also use classes. A simple example looks like this:

class Item {
  Item({required this.name, required this.title});

  final String name;
  final String title;
}

final itemProvider = StateProvider<Item>((ref) => Item(name: 'Item1', title: 'Title1'));
ref.read(itemProvider.notifier).state = Item(name: 'Item2', title: 'Title2');
ref.read(itemProvider.notifier).update((state) => Item(name: 'Item2', title: 'Title2'));

FutureProvider

FutureProvider works like other providers but for asynchronous code and returns a Future. They are generally used in place of FutureBuilder.

final itemProvider = FutureProvider<Item>((ref) async {
  return someLongRunningFunction();
});
AsyncValue<Item> futureItem = ref.watch(itemProvider);
  return futureItem.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (item) {
      return Text(item.name);
    },
  );

StreamProvider

You’ll learn about streams in detail in the next chapter. For now, you just need to know that Riverpod also has a provider specifically for streams and works the same way as FutureProvider. StreamProviders 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.

StateNotifierProvider

StateNotifierProvider is used to listen to changes in StateNotifier. A simple example looks like this:

class ItemNotifier extends StateNotifier<Item> {
  ItemNotifier() : super(Item(name: 'Item1', title: 'Title1'));

  void updateItem(Item item) {
    state = item;
  }
}

final itemProvider = StateNotifierProvider<ItemNotifier, Item>((ref) => ItemNotifier());
ref.read(itemProvider.notifier).updateItem(Item(name: 'Item2', title: 'Title2'));

NotifierProvider and AsyncNotifierProvider

NotifierProvider is used to listen to and expose a Notifier. AsyncNotifierProvider is a Notifier that you can asynchronously initialize. You generally use it to expose the state, which can change over time after reacting to custom events, like button taps and data changes.

class ItemNotifier extends Notifier<Item> {
  @override
  Item build(){
    return Item(name: 'Item1', title: 'Title1');
  }

  void updateItem(Item item) {
    state = item;
  }
}

final itemNotifierProvider = NotifierProvider<ItemNotifier, Item>(() => ItemNotifier());
ref.read(itemNotifierProvider.notifier).updateItem(Item(name: 'Item2', title: 'Title2'));

Adopting Riverpod in the Recipe Finder App

You’re now ready to start working on your recipe project. If you’re following along with your app from the previous chapters, open it and keep using it with this chapter. If not, just locate this chapter’s projects folder and open starter in Android Studio.

Overview of Existing Providers

Open up providers.dart. It should look like this:

// 1
final sharedPrefProvider = Provider<SharedPreferences>((ref) {
  throw UnimplementedError();
});

// 2
final repositoryProvider = ChangeNotifierProvider<MemoryRepository>((ref) {
  return MemoryRepository();
});

// 3
final serviceProvider = Provider<ServiceInterface>((ref) {
  throw UnimplementedError();
});
// 1
final sharedPrefs = await SharedPreferences.getInstance();
// 2
final service = SpoonacularService.create();
// 3
runApp(ProviderScope(overrides: [
  sharedPrefProvider.overrideWithValue(sharedPrefs),
  serviceProvider.overrideWithValue(service),
], child: const MyApp()));

Updating Repositories

Inside the data/repositories directory are two repository files: repository.dart contains the abstract definition of a repository, and memory_repository.dart defines a memory-based repository. This repository will hold your recipes and ingredients while running. Once the app closes, the data goes away. In Chapter 15, “Saving Data Locally”, you’ll learn how to store such data locally.

Updating the Memory Repository

Open up data/repositories/memory_repository.dart. Notice that it currently uses `ChangeNotifier, which isn’t recommended when using Riverpod. You’ll convert this class to the Riverpod Notifier class.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'models.dart';
part 'current_recipe_data.freezed.dart';

@freezed
class CurrentRecipeData with _$CurrentRecipeData {
  const factory CurrentRecipeData({
    @Default(<Recipe>[]) List<Recipe> currentRecipes,
    @Default(<Ingredient>[]) List<Ingredient> currentIngredients,
  }) = _CurrentRecipeData;
}
dart run build_runner build
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/current_recipe_data.dart';
class MemoryRepository extends Notifier<CurrentRecipeData>
    implements Repository {
@override
CurrentRecipeData build() {
  const currentRecipeData = CurrentRecipeData();
  return currentRecipeData;
}
@override
List<Recipe> findAllRecipes() {
  return state.currentRecipes;
}
if(state.currentRecipes.contains(recipe)) {
  return 0;
}
state = state.copyWith(currentRecipes: [...state.currentRecipes, recipe]);
state = state.copyWith(currentIngredients: [...state.currentIngredients,
  ...ingredients]);
final updatedList = [...state.currentRecipes];
updatedList.remove(recipe);
state = state.copyWith(currentRecipes: updatedList);
final updatedList = [...state.currentIngredients];
updatedList.remove(ingredient);
state = state.copyWith(currentIngredients: updatedList);
final updatedList = [...state.currentIngredients];
updatedList.removeWhere((ingredient) => ingredients.contains(ingredient));
state = state.copyWith(currentIngredients: updatedList);
final updatedList = [...state.currentIngredients];
updatedList.removeWhere((ingredient) => ingredient.recipeId == recipeId);
state = state.copyWith(currentIngredients: updatedList);
import 'data/models/current_recipe_data.dart';
final repositoryProvider =
    NotifierProvider<MemoryRepository, CurrentRecipeData>(() {
  return MemoryRepository();
});

Using the Repository for Recipes

You’ll implement code to add a recipe to the Bookmarks screen and ingredients to the Groceries screen. First, open ui/recipes/recipe_details.dart.

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 = ref.read(repositoryProvider.notifier);
repository.insertRecipe(recipeDetail!);
repository.deleteRecipe(recipeDetail!);

Implementing the Bookmarks Screen

Open ui/bookmarks/bookmarks.dart and add the following imports:

import '../../providers.dart';
import '../recipes/recipe_details.dart';
final repository = ref.watch(repositoryProvider);
recipes = repository.currentRecipes;
  void deleteRecipe(Recipe recipe) {
    ref.read(repositoryProvider.notifier).deleteRecipe(recipe);
  }
deleteRecipe(recipe);
Navigator.push(context, MaterialPageRoute(
  builder: (context) {
    return RecipeDetails(
        recipe: recipe.copyWith(bookmarked: true));
  },
));

Implementing the Groceries Screen

Open ui/groceries/groceries.dart and add the following:

import '../../providers.dart';
  final repository = ref.watch(repositoryProvider);
  currentIngredients = repository.currentIngredients;
  final repository = ref.watch(repositoryProvider);
  currentIngredients = repository.currentIngredients;

Implementing the Main Screen State

The main screen also has a state, and that is the currently selected bottom navigation item. This state will use the StateProvider class from Riverpod.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'main_screen_state.freezed.dart';

// 1
@freezed
class MainScreenState with _$MainScreenState {
  const factory MainScreenState({
    @Default(0) int selectedIndex,
  }) = _MainScreenState;
}

// 2
class MainScreenStateProvider extends StateNotifier<MainScreenState> {
  MainScreenStateProvider() : super(const MainScreenState());

  // 3
  void updateSelectedIndex(int index) {
    state = MainScreenState(selectedIndex: index);
  }
}
dart run build_runner build
import 'ui/main_screen_state.dart';

final bottomNavigationProvider =
    StateNotifierProvider<MainScreenStateProvider, MainScreenState>((ref) {
  return MainScreenStateProvider();
});
final bottomNavigation = ref.read(bottomNavigationProvider);
prefs.setInt(prefSelectedIndexKey, bottomNavigation.selectedIndex);
ref
    .read(bottomNavigationProvider.notifier)
    .updateSelectedIndex(index);
ref.read(bottomNavigationProvider.notifier).updateSelectedIndex(index);
selectedIndex:
    ref.watch(bottomNavigationProvider).selectedIndex,
index: ref.watch(bottomNavigationProvider).selectedIndex,
ref.watch(bottomNavigationProvider).selectedIndex == 0
    ? selectedColor
    : Colors.black,
ref.watch(bottomNavigationProvider).selectedIndex == 0
    ? selectedColor
    : Colors.black,
index: ref.watch(bottomNavigationProvider).selectedIndex,
final bottomNavigationIndex =
    ref.read(bottomNavigationProvider).selectedIndex;
bottomNavigationIndex
void _onItemTapped(int index) {
  ref.read(bottomNavigationProvider.notifier).updateSelectedIndex(index);
  saveCurrentIndex();
}
void getCurrentIndex() async {
  final prefs = ref.read(sharedPrefProvider);
  if (prefs.containsKey(prefSelectedIndexKey)) {
    final index = prefs.getInt(prefSelectedIndexKey);
    if (index != null) {
      ref.read(bottomNavigationProvider.notifier).updateSelectedIndex(index);
    }
  }
}
Future.microtask(() async {
  getCurrentIndex();
});

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 Riverpod 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 stores. The flow looks like this:

Axpusov Pruko Ipzezuz Wgoye Oxkaew Kawuhewq Hmozi Jivgiqewzb

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 others 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:

Key Points

  • State management is key to Flutter development.
  • Riverpod is a great package that helps with state management.
  • Other packages for handling application state include Redux, Bloc, and MobX.
  • Repositories are a pattern for providing data.
  • You can switch between repositories by providing an interface for the repository. 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 reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now