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

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.

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

There are two types of state to consider - ephemeral state, also known as local state, which is limited to the widget, and app state, also known as global 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 to manage the entire state of the app and when other parts of your app need to access some state data. One example is an image that changes over time, like an icon for the current weather. Another example is information that the user selects on one screen, 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 when there’s any change in the widget’s state.

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

RecipeList Card Gridview PopupMenuButton List<Recipe> previousSearches Widgets RecipeList State

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

Card GridView ListTile Card Element GridView Element List Tile Element Widget Tree Element Tree

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

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?

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

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.

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

Widgets Widget Data Repository Local Storage Network Data

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.

When you create a StatefulWidget, the createState() method gets called, which creates and stores the state internally in Flutter. The parent needs to rebuild the widget when there’s a change in the state of the widget.

You use initstate() to initialize the widget in its starting state. You use it for one-time work, like initializing text controllers. Then, you use setState() to set the new changed state, triggering a rebuild of the widget.

For example, in Chapter 10, “Handling 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 an internal state, but not for a state outside the widget.

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

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

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

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

}

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.

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

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

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

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.

Provider

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

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

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. Easily access state from anywhere.
  2. Allow the combination of states.
  3. Enable override providers for testing.

You’ll use RiverPod to implement state management in your app.

Keypoints of Riverpod

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

  • ProviderScope: A provider scope is a widget that provides a scope for providers. The AppWidget must be wrapped in ProviderScope to use Riverpod.
  • Provider: A provider is a class that provides a value to other classes. It’s the most basic class in Riverpod. There are many types of providers. You’ll see them later.
  • Consumer: A consumer is a widget that listens to changes in a provider and rebuilds itself when the value changes. There are two types of consumers: Consumer and ConsumerWidget. You’ll see examples later.
  • Ref: A ref is a reference to a provider. You use it to access other providers. You can obtain a ref from providers and ConsumerWidgets.

Types of Providers

There are several different types of providers:

  • Provider: Returns any value. Useful as DI.
  • StateProvider: Returns any type and provides a way to modify it’s state.
  • FutureProvider: Returns a Future.
  • StreamProvider: Returns a Stream.
  • StateNotifierProvider: Returns a subclass of StateProvider and provides a way to modify its state through an interface.
  • NotifierProvider: Listen to and expose a Notifier.
  • AsyncNotifierProvider: Listen to and expose an Asyncotifier, AsyncNotifier is a Notifier that can be asynchronously initialized.
  • ChangeNotifierProvider: Returns a ChangeNotifier. This is for migrating from the old ChangeNotifier.

Note: ChangeNotifierProvider is a mutable provider, and its use is discouraged. It’s only for transitioning from provider to Riverpod. It’s advisable to use NotifierProvider instead.

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

The variable myProvider is final and doesn’t change. It provides a function that will create the state. You can also use the ref variable to access other providers. You can also provide multiple providers that return the same type.

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

The variable itemProvider is final and doesn’t change. You use this variable to access the state of the value provided by the provider and can change the value as follows:

ref.read(itemProvider.notifier).state = Item(name: 'Item2', title: 'Title2');

There is also the update() method:

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

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. You can use FutureProvider like this:

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

Here the constructor of ItemNotifier sets the initial state for an Item. To change the value of the provider, you use its updateItem() method as follows:

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

The build() function returns the initial state of the Item and is called when the provider is first accessed.

To change the value of the provider, you use again its updateItem() method:

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.

Note: If you use the starter app, don’t forget to add your apiKey in network/spoonacular_service.dart.

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

This code:

  1. Defines a provider for Shared preferences. Note that it throws an UnimplementedError. Explanation below.
  2. Defines a ChangeNotifierProvider for the MemoryRepository.
  3. Defines a provider for ServiceInterface. This will allow you to substitute any ServiceInterface class.

Now open main.dart and look at the following:

// 1
final sharedPrefs = await SharedPreferences.getInstance();
// 2
final service = SpoonacularService.create();
// 3
runApp(ProviderScope(overrides: [
  sharedPrefProvider.overrideWithValue(sharedPrefs),
  serviceProvider.overrideWithValue(service),
], child: const MyApp()));

  1. Get an instance of the SharedPreferences library.
  2. Create a SpoonacularService.
  3. Override the definitions above with these newly created instances.

Since getting a shared preference instance is an asynchronous call, we do this in the main method that uses the async keyword.

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.

To be a Notifier - a class has to have an object that notifies others about the change. This class will be CurrentRecipeData. This will contain the current recipes and ingredients list. Create a new file in data/models called current_recipe_data.dart.

Add the following:

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

This uses the Freezed package to create a few helper methods like copyWith(). The @Default annotation helps assign the default value to the variables. From a terminal run:

dart run build_runner build

This will create the current_recipe_data.freezed.dart file.

Back in memory_repository.dart, replace the import of foundation.dart with:

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/current_recipe_data.dart';

Then change the class definition to:

class MemoryRepository extends Notifier<CurrentRecipeData>
    implements Repository {

On the next line, add the following method. This will initialize the notifier and set the initial state of CurrentRecipeData.

@override
CurrentRecipeData build() {
  const currentRecipeData = CurrentRecipeData();
  return currentRecipeData;
}

Now, remove all of the calls to notifyListeners() methods, as they’ll be throwing compilation errors. Next, remove the declaration of _currentRecipes and _currentIngredients fields. Since those two fields are in CurrentRecipeData and we have a state of currentRecipeData, you’ll just use that.

Substitute all the occurrences of _currentRecipes with state.currentRecipes.

For example, findAllRecipes() should turn into this:

@override
List<Recipe> findAllRecipes() {
  return state.currentRecipes;
}

Similarly, change all occurrences of _currentIngredients with state.currentIngredients.

Note: State is a getter that returns the current state of the notifier and you can access the current state. You can also update the state of the notifier by assigning new state. You don’t need to call notifyListeners() as it’s done automatically.

If you need help, look at the file in the final project. Hint - find and replace works great!

Since CurrentRecipeData is immutable, meaning you can’t modify it, you’ll have to create new instances instead of modifying the lists. Where are the lists modified, you may wonder? In the methods that insert and delete recipes and ingredients. Find // TODO: Update insertRecipe() and replace the line below it with:

if(state.currentRecipes.contains(recipe)) {
  return 0;
}
state = state.copyWith(currentRecipes: [...state.currentRecipes, recipe]);

First, you check if the recipe is already on the list. If it is, you return 0. If not, you assign the current state with a new instance of state of CurrentRecipeData by copying the existing one (copyWith() comes from Freezed) with the current list of recipes and a new one. Notice that using [] makes a new list.

Now replace the line below // TODO: Update insertIngredients() with:

state = state.copyWith(currentIngredients: [...state.currentIngredients,
  ...ingredients]);

This does something similar but adds two lists together. Next, replace // TODO: Update deleteRecipe() and the subsequent line with the following:

final updatedList = [...state.currentRecipes];
updatedList.remove(recipe);
state = state.copyWith(currentRecipes: updatedList);

This creates a new list using the spread operator: ..., which unfolds the list of items. Now replace the whole body of deleteIngredient() with:

final updatedList = [...state.currentIngredients];
updatedList.remove(ingredient);
state = state.copyWith(currentIngredients: updatedList);

Then substitute the whole body of deleteIngredients() with:

final updatedList = [...state.currentIngredients];
updatedList.removeWhere((ingredient) => ingredients.contains(ingredient));
state = state.copyWith(currentIngredients: updatedList);

And finally, change the body of deleteRecipeIngredients() as follows:

final updatedList = [...state.currentIngredients];
updatedList.removeWhere((ingredient) => ingredient.recipeId == recipeId);
state = state.copyWith(currentIngredients: updatedList);

Now that MemoryRepository has changed, open lib/providers.dart to adopt NotifierProvider. Add the following import:

import 'data/models/current_recipe_data.dart';

and then change repositoryProvider to:

final repositoryProvider =
    NotifierProvider<MemoryRepository, CurrentRecipeData>(() {
  return MemoryRepository();
});

Rerun your app to make sure it compiles successfully.

It’s now time to use the new repositoryProvider in the UI.

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.

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

Find // TODO: Add Repository and replace it with:

final repository = ref.read(repositoryProvider.notifier);

This reads the repositoryProvider as a class instance so that you can use it to access the functions you defined in the repository. You’ll use it to add the bookmark.

Next, replace // TODO: Insert Recipe with:

repository.insertRecipe(recipeDetail!);

This adds the recipe to your repository’s list of recipes. To delete the recipe, replace: // TODO: Delete Recipe with:

repository.deleteRecipe(recipeDetail!);

This just removes it from the memory repository’s list of recipes.

Now, hot reload 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:

Tap 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 it yet.

Showing bookmarked recipes in the Bookmarks tab is your next step.

Implementing the Bookmarks Screen

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

import '../../providers.dart';
import '../recipes/recipe_details.dart';

This includes the Riverpod providers to retrieve the repository as well as the RecipeDetails class.

Find // TODO: Add Repository and add:

final repository = ref.watch(repositoryProvider);
recipes = repository.currentRecipes;

This watches the repository for changes and updates the widget. It also gets the current list of recipes from the repository.

On the Bookmarks page, the user can delete a bookmarked recipe by swiping left or right and selecting the delete icon. To implement this, find and replace // TODO: Add Delete Recipe at the bottom of the class with:

  void deleteRecipe(Recipe recipe) {
    ref.read(repositoryProvider.notifier).deleteRecipe(recipe);
  }

In this code, you use: ref.read to get the repository and then call deleteRecipe() on it.

Go back up in the file and replace the two instances of // TODO Add Delete with:

deleteRecipe(recipe);

This will call the method you just created and pass the recipe to delete. Replace // TODO: Add Push to Recipe Details Page with:

Navigator.push(context, MaterialPageRoute(
  builder: (context) {
    return RecipeDetails(
        recipe: recipe.copyWith(bookmarked: true));
  },
));

This will take the user to the recipe details page with a copy of the recipe and bookmarked set to true.

If you left your app running while making all of the above changes, hot reload the app.

If you stopped your app or did a hot restart instead of a hot reload, then return to the Recipes tab and bookmark a recipe.

Select the Bookmarks tab, and you should see the recipe you bookmarked. Something like this:

You’re almost done, but if you go to the Groceries tab, you’ll see that the view is currently blank. Your next step is to add the functionality to show the ingredients of bookmarked recipes.

Implementing the Groceries Screen

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

import '../../providers.dart';

Here, you import your providers.

Find // TODO: Add Repository 1 and replace with:

  final repository = ref.watch(repositoryProvider);
  currentIngredients = repository.currentIngredients;

Find // TODO: Add Repository 2 and replace with:

  final repository = ref.watch(repositoryProvider);
  currentIngredients = repository.currentIngredients;

Hot reload and make sure you still have one bookmark saved.

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 you can monitor state changes and get notifications across different screens, thanks to the infrastructure of Riverpod.

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.

In the ui directory, create a new file named main_screen_state.dart. Add the following:

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);
  }
}
  1. MainScreenState just holds the currently selected index.
  2. MainScreenStateProvider is a provider for that state.
  3. One method (updateSelectedIndex) is provided to update the index. It creates a new state.

This uses the Freezed package to create a few helper methods. From a terminal run:

dart run build_runner build

Now add this provider to providers.dart:

import 'ui/main_screen_state.dart';

final bottomNavigationProvider =
    StateNotifierProvider<MainScreenStateProvider, MainScreenState>((ref) {
  return MainScreenStateProvider();
});

Open up main_screen.dart and remove int _selectedIndex = 0;. Inside of saveCurrentIndex() replace the last line with:

final bottomNavigation = ref.read(bottomNavigationProvider);
prefs.setInt(prefSelectedIndexKey, bottomNavigation.selectedIndex);

Find // TODO: Update getCurrentIndex() and replace the line below it with:

ref
    .read(bottomNavigationProvider.notifier)
    .updateSelectedIndex(index);

Change the line below // TODO: Update _onItemTapped() with:

ref.read(bottomNavigationProvider.notifier).updateSelectedIndex(index);

Then find // TODO: Update largeLayout() 1 and replace the line below it with:

selectedIndex:
    ref.watch(bottomNavigationProvider).selectedIndex,

Finally find // TODO: Update largeLayout() 2 and replace the subsequent line with:

index: ref.watch(bottomNavigationProvider).selectedIndex,

The next step is to update getRailNavigations(). First, replace the line below // TODO: Update getRailNavigations() 1 with:

ref.watch(bottomNavigationProvider).selectedIndex == 0
    ? selectedColor
    : Colors.black,

And then change the line after // TODO: Update getRailNavigations() 2 with:

ref.watch(bottomNavigationProvider).selectedIndex == 0
    ? selectedColor
    : Colors.black,

Now find // TODO: Update mobileLayout() and change the line below it to:

index: ref.watch(bottomNavigationProvider).selectedIndex,

In createBottomNavigationBar() , find // TODO: Add index and replace it with:

final bottomNavigationIndex =
    ref.read(bottomNavigationProvider).selectedIndex;

Finally, change all of the remaining instances of _selectedIndex to:

bottomNavigationIndex

Now it’s time to get rid of the calls to setState(). Update _onItemTapped() so it looks like this:

void _onItemTapped(int index) {
  ref.read(bottomNavigationProvider.notifier).updateSelectedIndex(index);
  saveCurrentIndex();
}

and change getCurrentIndex() as follows:

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

Finally, make sure that getCurrentIndex() is called after the build method. To achieve that, change the last line of initState() like this.

Future.microtask(() async {
  getCurrentIndex();
});

Stop and restart the app and verify that you can add and delete bookmarks. Check also that the Groceries tab shows the ingredients of the bookmarked recipes.

Congrats! Now you know how to manage state across different screens of your app using Riverpod. And that’s just the beginning!

Is Riverpod the only option for state management? No. Here’s a quick tour of alternative libraries.

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.

Such libraries include Redux, BLoC and MobX. 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 stores. The flow looks like this:

Updated State Updated State Action Reducers Store Components

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.

You need two packages to use Redux in Flutter: redux and flutter_redux.

For React developers migrating to Flutter, an advantage of Redux is that it’s already familiar. It might take a bit to learn if you aren’t familiar with 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 others 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 has annotations that help you write your code and simplify it.

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.

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:

In the next chapter, you’ll learn all about streams that 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.