How I Simplified Flutter State Management using Riverpod
Introduction
When it comes to state management in Flutter, the Riverpod package often receives mixed reviews. While some developers appreciate its versatility, many find it overly complex due to its multiple types of providers. The lack of a standard pattern — something that libraries like Bloc offer — can further amplify this complexity. Consequently, developers often need to architect their own patterns, adding another layer of decision-making to their projects.
Despite these challenges, I believe that Riverpod offers an elegant and simplified way to manage state in Flutter. In this article, I’ll share my insights on how to reduce the perceived complexity of Riverpod by relying on just two types of providers. With this approach, you can efficiently handle:
- UI and Logic Separation
- Asynchronous Requests
- Stream Management
- Data Caching
- Targeted Widget Rebuilding
The goal here is to demonstrate that Riverpod can be simplified to suit the majority of your project needs, without even tapping into its full range of capabilities.
Before diving into the core content, it’s important to mention that this article is designed with beginners in mind. If you’re new to Flutter or have only recently started exploring state management options like Riverpod, this guide aims to make your journey smoother.
In the next section, I’ll cover some basic setup and usage of Riverpod. If you’re already familiar with the fundamentals of Riverpod, feel free to skip ahead.
Getting Started: Adding Riverpod to Your Project
Before we delve into the nitty-gritty of using Riverpod, let’s go through the initial setup steps. Adding Riverpod to your Flutter project is a simple two-step process:
Step 1: Add Dependency
Open your `pubspec.yaml` file and add the Riverpod package under the `dependencies` section:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.4.4 # Check for the latest version on pub.dev
After adding the dependency, run the following command to install the package:
flutter pub get
Step 2: Initialise Riverpod
To initialise Riverpod, you’ll need to wrap your main Flutter app widget with `ProviderScope`. Open your `main.dart` file and modify it as follows:
void main() {
runApp(
ProviderScope( // Wrap your app with ProviderScope
child: MyApp(),
),
);
}
One key detail worth mentioning about the `ProviderScope` is its role in your Flutter project. When you wrap your main app widget with `ProviderScope`, you’re essentially covering your entire widget tree with a layer that enables Riverpod’s state management capabilities. This makes it possible to access and manipulate your application’s state from anywhere within the widget tree, ensuring a flexible and decentralised approach to state management.
By placing `ProviderScope` at the root level, you grant all child widgets the ability to tap into the providers you define. This saves you from having to pass state information manually through constructors or callbacks, thereby simplifying your code structure and making it easier to manage. So, when you initialise Riverpod in your `main.dart` file as shown earlier, you’re setting the stage for an efficient and scalable state management architecture.
And that’s it! You’ve successfully added Riverpod to your project, and you’re ready to dive into simplified state management.
Declaring and observing state
Declaring a provider
Declaring a state using `StateProvider` is as simple as it gets. Here’s how you can declare a basic counter state:
final counterProvider = StateProvider<int>((ref) => 42);
Observing a provider and the important of `ref`
In the realm of Riverpod, one term you will encounter frequently is `ref`. While it may appear to be a simple argument, its importance cannot be overstated, especially in the context of a widget’s `build` method.
In typical Riverpod usage, `ref` is often utilised within the `build` method of a widget. Specifically, you use `ref.watch` to observe the state of a particular provider. When the observed state changes, the widget’s `build` method is triggered to run again, leading to a UI update.
class CounterDisplay extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider); //
return Text('Counter: $counter');
}
}
By making `ref.watch` a part of your `build` method, you are telling the widget to keep an eye on specific pieces of state and to rebuild the UI in a localised manner whenever that state changes. This focused, efficient updating is one of Riverpod’s major strengths, and it’s all orchestrated through the clever use of `ref`.
As we discuss the use of `ref` in Riverpod, it’s worth mentioning that there are two main locations where you can access this “reference” object:
- Within the Widget: To access `ref` from a widget, you’ll first need to convert your normal widget to what I like to call a “Riverpod stateless widget.” In the example above, you’ll see that we use `ConsumerWidget` instead of the typical `StatelessWidget`. By making this switch, the widget gains the ability to observe providers using `ref`.
- Within the Provider or its Notifier Class
We’ve just wrapped up our beginner’s guide to using Riverpod. Now, let’s dive into the main content of this article.
The Overwhelming Perception of Riverpod
The first issue many people have with Riverpod is its numerous types of providers. From `Provider` to `FutureProvider`, `StreamProvider`, and more, each serves a unique purpose, but their sheer number can be intimidating. This complexity is exacerbated by the absence of a “best practices” guide, leaving developers to form their own patterns.
A Simpler Way: Two Providers are Often Enough
Contrary to what one might assume, I’ve found that Riverpod’s capabilities can be simplified drastically without sacrificing functionality. By focusing on just two types of providers, you can cover a wide array of use-cases.
In the upcoming discussion, I’ll share insights into how I successfully built an internal app for my company that encompasses a wide range of features — CRM, Inventory Control, HR, File Management, and more. I managed to accomplish all of this by using just two types of providers: `AsyncNotifierProvider` and `Provider`. This streamlined approach simplifies the complexity and allows for easier maintenance, and I’m excited to walk you through how you can achieve similar results in your own projects.
While the author of Riverpod recommends using code generation for a more streamlined approach, I’ve chosen to manually implement providers for this article. This approach aims to give you a clearer, hands-on understanding of how everything works.
Separating UI and Logic: A Clean Approach with Riverpod
One of the most crucial practices in software development is the separation of concerns, and this is especially true in Flutter apps. In this next section, I’ll demonstrate how you can effectively separate your UI and business logic using Riverpod’s `AsyncNotifierProvider` and `Provider`. The example will be based on a product listing feature for an app.
File Structure
To kick off our feature development, we’ll create two principal Dart files:
1. `products_view_model.dart`: This file serves as the cornerstone of our application’s business logic. It utilises the `AsyncNotifierProvider` to manage all the state and operations that `products_view.dart` will subsequently display.
2. `products_view.dart`: This file is exclusively tasked with rendering the UI components. It will display the list of products, facilitate product filtering, and indicate pagination statuses.
Creating View State
Let’s commence by establishing a view state class within our `products_view_model.dart` file.
In many instances, even a basic view necessitates the management of multiple states. These could range from the actual data set to be displayed, to filter configurations, and pagination statuses.
In this particular example, we’ll introduce a class named `ProductsViewState` as follows:
class ProductViewState {
final List<Product>? products;
final Product? selectedProduct;
final ProductSize? sizeFilter;
final Supplier? supplierFilter;
const ProductViewState({
this.products,
this.selectedProduct,
this.sizeFilter,
this.supplierFilter,
});
}
Creating View Model
Now, let’s delve into the exciting part where we construct the `provider` that will manage our products view. This takes place within the same `products_view_model.dart` file.
For this example, I’m opting for an `AsyncNotifierProvider` as we need to asynchronously fetch our product list from a remote database. The declaration of this provider involves two key components:
- Notifier Class: This is where our `ProductsViewState` and associated logic reside.
- Provider Itself: In layman’s terms, this component registers our `notifier` with Riverpod and enables its access from anywhere within our application where `.ref` is available.
Creating AsyncNotifier
In our example, the `notifier` class will be an `AsyncNotifier`, which can be defined as follows:
class ProductViewNotifier extends AsyncNotifier<ProductViewState> {
@override
FutureOr<ProductViewState> build() async {
return ProductViewState();
}
}
This code snippet establishes a `ProductsViewNotifier` class to act as our view model, guaranteeing a return of `ProductsViewState`. The intriguing aspect here is the `build()` method, which gets invoked whenever the provider is initialised for the first time. (I’ll discuss the initialisation of this provider in a later section).
Transferring `setState()` to AsyncNotifier
The crux of my implementation strategy is to relocate all UI logic into a view model, which in this instance is encapsulated within the `AsyncNotifier`. The objective is to mimic the functionality of the `setState()` method commonly found in a stateful widget but within the realm of our view model.
To accomplish this, I will copy the list of variables from `ProductsViewState` into `ProductsViewNotifier` and construct a `setState()` method as follows:
class ProductViewNotifier extends AsyncNotifier<ProductViewState> {
//----Products View State -----
List<Product> products = const [];
Product? selectedProduct;
ProductSize? sizeFilter;
Supplier? supplierFilter;
//-----------------------------
@override
FutureOr<ProductViewState> build() async {
return ProductViewState();
}
void setState() {
state = AsyncData(ProductsViewState(
products: products,
selectedProduct: selectedProduct,
sizeFilter: sizeFilter,
supplierFilter: supplierFilter,
));
}
}
You may be wondering why we need a parallel set of state variables within our `notifier` and state class.
Here’s why:
- Eliminates the need for repetitive `state.value.[variableName]` when accessing state variables.
- Invoking `setState()` in this manner is more streamlined compared to using `state = AsyncData(state.value.copyWith())`.
Utilising Provider for Dependency Injection
Before advancing further with our view model, it’s crucial to declare its dependencies. For instance, consider the `ProductUseCase` class where we implement the `getProducts()` method.
class ProductUseCase {
final ProductRepository _productRepository;
ProductUseCase({required ProductRepository productRepository})
: _productRepository = productRepository;
Future<List<Product>> getProducts() async {
return _productRepository.getProducts();
}
}
As evident, the `ProductUseCase` class relies on the `ProductRepository` to interact with our database.
Initialisation of these dependent classes can be straightforwardly accomplished using the `Provider` as demonstrated below:
final productRepositoryProvider = Provider<ProductRepository>((ref) {
return ProductRepository();
});
final productUseCaseProvider = Provider<ProductUseCase>((ref) {
final productRepository = ref.watch(productRepositoryProvider);
return ProductUseCase(productRepository: productRepository);
});
By utilising `ref.watch(productRepositoryProvider)`, we ensure that any updates to the state of `productRepositoryProvider` automatically trigger updates in the state of `productUseCaseProvider`.
Applying this same principle to our `notifier`, we proceed as follows:
class ProductViewNotifier extends AsyncNotifier<ProductViewState> {
late ProductUseCase _productUseCase; // Declare use case
late List<Product> _cacheProducts; // Declare variable to cache product data
//----Products View State -----
List<Product> products = const [];
Product? selectedProduct;
ProductSize? sizeFilter;
Supplier? supplierFilter;
//-----------------------------
@override
FutureOr<ProductViewState> build() async {
_productUseCase = ref.watch(productUseCaseProvider); // (1)
_cacheProducts = await _productUseCase.getProducts();// (2)
products = [..._cacheProducts]; // (3)
return ProductViewState(
products: products,
selectedProduct: selectedProduct,
sizeFilter: sizeFilter,
supplierFilter: supplierFilter,
); // (4)
}
void setState() {
state = AsyncData(ProductsViewState(
products: products,
selectedProduct: selectedProduct,
sizeFilter: sizeFilter,
supplierFilter: supplierFilter,
));
}
}
You’ll likely have noted that we can now make our network request directly within the `build()` method of the `ProductViewNotifier` class. This action will be triggered when our widget first initialises.
1. Dependency injection for `ProductUseCase` is accomplished using `ref.watch()`.
Just like in the earlier `Provider` example, updates to `productUseCaseProvider` will notify its listeners. This, in turn, triggers the `build()` method, updating the state accordingly.
2. Executes the network request and stores the result in `_cachedProducts`.
3. Copies the data to the `products` variable, and then
4. Returns the state to be rendered in the UI.
With this setup, you’re free to manipulate the view state according to your project’s logic, invoking `setState()` to update the UI whenever needed. For instance:
set setSelectedProduct(Product product) {
selectedProduct = product;
setState();
}
set setSizeFilter(ProductSize? size) {
sizeFilter = size;
if (size != null) {
products = _cacheProducts.where((element) => element.size == size).toList();
setState();
} else {
products = [..._cacheProducts];
setState();
}
}
void searchProduct(String? searchText) {
if (searchText != null && searchText.isNotEmpty) {
products = _cacheProducts
.where((element) =>
element.name.toLowerCase().contains(searchText.toLowerCase()))
.toList();
setState();
} else {
products = _cacheProducts;
setState();
}
}
Future<void> fetchProductsBySupplier(Supplier supplier) async {
try {
state = const AsyncLoading();
supplierFilter = supplier;
products = await _productUseCase.getProductsBySupplier(supplier);
setState();
} on Exception catch (e, stackTrace) {
state = AsyncError(e, stackTrace);
}
}
All methods described above can be invoked from any widget within the widget tree, automatically refreshing widgets that depend on `ProductViewState`. Let’s break down a few examples:
- `setSelectedProduct()`: This function simply sets the currently selected product.
- `setSizeFilter()`: Leveraging cached data, this method filters the product list by size. If no size filter is specified (i.e., `sizeFilter == null`), it returns the original list of products.
- `searchProduct()`: Similar to `setSizeFilter()`, this implementation uses cached data to search for products by name.
- `fetchProductsBySupplier()`: While we could implement this similarly to `setSizeFilter()`, I want to demonstrate that asynchronous calls aren’t confined to the `build()` method. Here, we fetch a fresh list of products filtered by supplier directly from the database.
You may have noticed that the state of `AsyncNotifier` can be one of the following:
- `AsyncLoading()`
- `AsyncData()`
- `AsyncError()`
In the next section, I’ll demonstrate how effortlessly you can handle these states in the UI. Although these states are automatically managed within the `build()` method, manual handling is required outside it — as shown in `fetchProductsBySupplier()`.
The instance of `ProductViewNotifier` is persistent, irrespective of whether the `build()` method is invoked or its `state` is set. This means that cached data remains available across widget lifecycles. For further insights, I recommend reading about
UI Code
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:warehouse/product/presentation/product_view_model.dart';
class ProductView extends ConsumerWidget {
const ProductView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue viewState = ref.watch(productViewProvider);
ProductsViewNotifier viewModel = ref.watch(productViewProvider.notifier);
return Scaffold(
body: viewState.when(
data: (state) {
return // Your UI code here.
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text(error.toString())),
));
}
}
The `ref.watch()` method serves as the entry point for our view model. Upon the initial load of `ProductView`, `ProductViewNotifier` is lazily instantiated, triggering its `build()` method for the first time.
The code above is fairly self-explanatory. It demonstrates how to access both the `viewState` to determine what the UI should display and the `viewModel` to utilise our logic methods.
- `ref.watch(productViewProvider)` ensures that the `Widget build()` method is called to refresh the view whenever the `state` is updated.
- `ref.watch(productViewProvider.notifier)` provides access to our `notifier` class, granting us the capability to invoke our business logic methods.
The `viewState` variable is of type `AsyncValue`, which exposes the `when()` method. This method accepts three functions to handle scenarios where data is loading, successfully returned, or has encountered an error.
In this example:
- A loading indicator is displayed when the data is in the `AsyncLoading` state.
- An error message is displayed if an error occurs while fetching data.
- A list view of products along with filter buttons is shown upon successful data retrieval.
The full UI code would look something like this:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:warehouse/product/presentation/product_view_model.dart';
class ProductView extends ConsumerWidget {
const ProductView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue viewState = ref.watch(productViewProvider);
ProductsViewNotifier viewModel = ref.watch(productViewProvider.notifier);
return Scaffold(
body: viewState.when(
data: (state) {
return Column(children: [
Row(
children: [
DropdownButton<ProductSize>(
items: ProductSize.values
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name),
))
.toList(),
onChanged: (size) {
size != null ? viewModel.setSizeFilter = size : null;
}),
DropdownButton<Supplier>(
items: [
const Supplier('Nike'),
const Supplier('Adidas'),
const Supplier('Sketchers')
]
.map((e) => DropdownMenuItem(
value: e,
child: Text(e.name),
))
.toList(),
onChanged: (supplier) async {
supplier != null
? viewModel.fetchProductsBySupplier(supplier)
: null;
}),
],
),
Expanded(
child: ListView.builder(
itemCount: state.products?.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(state.products?[index].name ?? ''),
subtitle: Text(state.products?[index].size.name ?? ''),
onTap: () {
state.products?[index] != null
? (viewModel.setSelectedProduct =
state.products![index])
: null;
},
);
}))
]);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text(error.toString())),
));
}
}
With the above code, you can see how streamlined the UI code becomes when all of its logic is moved to a separate file. This not only simplifies the UI but also makes it easier to maintain. Accessing the desired state and invoking logic methods become straightforward and reactive.
Invoking UI methods is as simple as calling:
- `viewModel.setSizeFilter(size)` and
- `viewModel.fetchProductsBySupplier(supplier)`
These calls can be conveniently placed in the `onChanged()` method of a `DropdownButton`, making the UI extremely responsive.One of the most attractive aspects of this architecture is its reusability. The same view model can be easily adapted to serve multiple views, such as:
- Updating Product View
- Creating New Product View
- Or even forming part of a Create Order View, integrated as a Product Search Bar
This adaptability is possible with the addition of just a few simple methods, reducing redundancy and increasing maintainability.
Bonus: optimising your app by rebuilding selected widget
So far, we’ve taken a relatively straightforward approach to Riverpod, focusing on `AsyncNotifierProvider` and `Provider` for our state management needs. However, Riverpod offers a suite of additional tools to customise your state management further, making your code more efficient and modular.
- AutoDispose: Adding an `autoDispose` modifier can automatically dispose of a provider when it is no longer in use. This aids in resource clean-up, prevents memory leaks, and generally enhances app performance.
- Family: The `family` modifier lets you spawn a dynamic set of providers based on parameters. This adds a layer of versatility to your state management architecture.
One feature that showcases Riverpod’s advanced capabilities is the `select()` method. This method empowers you to specify which parts of the state should cause a widget to rebuild, thereby minimising unnecessary rebuilds and enhancing performance.
For example, to listen only to changes in the supplier filter, you could use the following:
Supplier? supplier = ref.watch(productViewProvider.select((value) => value.value?.supplierFilter));
This selective state listening is a powerful tool to optimise the performance of Flutter apps.
If these topics pique your interest, please leave a comment. Based on the demand, I will create an in-depth tutorial that delves into how these methods and modifiers work in Riverpod.
Conclusion
The central idea behind this approach is the utilisation of just two providers — `AsyncNotifierProvider` and `Provider` — as the single source of truth for both your UI logic and UI state. This design pattern streamlines your Flutter application in a way that significantly enhances maintainability and testability.
In an ideal setup, files that manage your UI — like `products_view.dart` — should only serve the role of rendering widgets and presenting data. These files should be free from any business logic and should preferably not maintain any state of their own. The only exception to this rule would be UI-specific states managed by objects like `TextEditingController` or `AnimationController`.
By confining your business logic and state management to the providers, your UI files become far simpler and more focused on their primary role: presentation. This makes it easier to manage the application and write unit tests, as each piece of code has a clearly defined responsibility.