Riverpod Essentials: Mastering State Management in Flutter Apps

A Reactive Caching and Data-binding Framework

Kyaw Thet Wam
14 min readMay 24, 2024

· Introduction
· What is Riverpod?
· Why Riverpod?
· Installing the Package
· ProviderScope
· Defining a Provider
· Consuming a Provider
· What is WidgetRef?
· Exploring Different Types of Providers
· Additional Riverpod Features
Provider Lifecycles
Destruction of a provider
Debouncing and Cancelling network requests
Scoping Providers
Filtering widget/provider rebuild using “select” and “selectAsync” .
· Conclusion
· References

Introduction

State management is crucial in Flutter, ensuring the app’s UI reacts correctly to user interactions and data changes. In Flutter, state refers to the data affecting the UI at any given moment. This includes both ephemeral state (local to a single widget) and app state (shared across the app).

Managing state efficiently is essential for maintaining a responsive and interactive UI. It improves code organization, scalability, and reusability.

Choosing the right solution depends on app complexity and specific needs. For simple apps, setState or InheritedWidget may be sufficient. For more complex needs, Provider, Riverpod, or Bloc (which uses Provider mechanism internally) are more appropriate.

In this article, we will focus on Riverpod, exploring its features, benefits, and practical examples to help you implement effective state management in your Flutter applications.

riverpod.dev

What is Riverpod?

Riverpod, an anagram of Provider, is a reactive caching framework for Flutter and Dart and a comprehensive rewrite of the Provider package, designed to introduce significant improvements and new capabilities that were previously unattainable. It simplifies application logic by leveraging declarative and reactive programming paradigms. Riverpod efficiently handles network requests with built-in error handling and caching mechanisms, and it automatically re-fetches data when needed.

Why Riverpod?

The Riverpod package, as the successor of Provider, addresses several limitations of its predecessor:

Single Type Limitation:

Provider, being an InheritedWidget wrapper, can only obtain one of two different providers using the same type, due to the constraints of the InheritedWidget API. Details.

Single Value Emission:

Providers emit only one value at a time, which means users cannot immediately see existing data while new data is being loaded. This impacts UI transitions, making them less smooth, and increases perceived waiting times.

Combining Providers:

  • Manual Dependency Management: Managing dependencies manually when multiple providers depend on each other is complex and error-prone.
  • Verbose and Redundant Code: Combining multiple providers often results in boilerplate code, making the codebase verbose and harder to maintain.
  • Initialization Order Issues: Ensuring the correct initialization order of providers is tricky and can lead to runtime errors if a provider is used before it’s ready.
  • Difficulty in Debugging: Debugging becomes challenging with multiple interdependent providers, as identifying the root cause requires tracing through numerous providers and their interactions.
https://tenor.com/

Lack of Safety:

Provider relies on the widget tree, which can lead to ProviderNotFoundException errors. This dependency on the widget tree is a fundamental design flaw that affects its reliability.

CODE WITH ANDREA

State Disposal Difficulty:

Provider, as a wrapper around InheritedWidget, inherits its limitations, such as the inability to dispose of state when no longer used, leading to memory leaks. Riverpod addresses this with intuitive APIs like autodispose and keepAlive, which support flexible and creative caching strategies.

Triggering Side Effects:

InheritedWidget, used by Provider for state management, lacks an onChange callback to notify value changes. This makes it difficult to trigger side effects (e.g., showing a snackbar) directly, complicating the code and reducing state management efficiency.

Riverpod overcomes these limitations with a more robust and user-friendly API, improving state management and overall app performance.

Installing the Package

dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5

dev_dependencies:
build_runner:
custom_lint:
riverpod_generator: ^2.4.2
riverpod_lint: ^2.3.12

riverpod_annotation: is a side package for riverpod_generator, exposing annotations

custom_lint: serves to enhance code quality and consistency by allowing developers to define their own linting rules.

riverpod_generator: is a side package for Riverpod, meant to offer a different syntax for defining “providers” by relying on code generation.

riverpod_lint: Its primary purpose is to enforce best practices and guidelines specific to Riverpod usage.

ProviderScope

To enable widgets to read providers, the entire application needs to be wrapped in a ProviderScope widget. This ensures that the state of all providers is properly stored and managed.

void main() async {
runApp(const ProviderScope(child: AppWidget()));
}

Defining a Provider

When defining providers via code generation, you can choose between annotated functions or classes. Class-based providers offer the advantage of including public methods for external state modification, enabling side-effects. Functional providers, essentially shorthand for class-based ones with just a build method, lack this flexibility for UI modification.

// Functional (Can’t perform side-effects using public methods)
@riverpod
String helloWorld(HelloWorldRef ref) {
return 'Hello world';
}

// Class-Based (Can perform side-effects using public methods)
@riverpod
class Example extends _$Example {
@override
String build() {
return 'foo';
}

// Add methods to mutate the state
}

Consuming a Provider

When accessing a provider’s value, we utilize widgets like Consumer, ConsumerWidget, or ConsumerStatefulWidget from the Riverpod package. These widgets enable us to observe changes in one or more providers and update relevant parts of our UI accordingly. They handle subscription management to the provider(s) automatically, triggering the build method whenever there’s a data change.
Why do we need ConsumerWidget instead of StatelessWidget or StatefulWidget? See the answer.

// Extend ConsumerWidget instead of StatelessWidget, which is exposed by Riverpod
class MyApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final String value = ref.watch(helloWorldProvider);

return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Example')),
body: Center(
child: Text(value),
),
),
);
}
}

What is WidgetRef?

An object that allows widgets to interact with providers, be it from a widget or another provider. We can use ref to interact with providers in several ways:

  1. Obtaining the value and listening to changes: Use ref.watch to obtain the value of a provider and automatically rebuild the widget or provider when the value changes.
  2. Adding a listener: Use ref.listen to add a listener to a provider, allowing you to execute actions (like navigating to a new page or showing a modal) whenever the provider's value changes.
  3. Obtaining the value without listening to changes: Use ref.read to get the current value of a provider without subscribing to updates, useful for events like "on click."

All the methods mentioned above can be utilized within either a widget or another provider.


@riverpod
String value(ValueRef ref) {
// use ref to obtain other providers
final repository = ref.watch(repositoryProvider);
return repository.get();
}

Exploring Different Types of Providers

Riverpod offers eight different types of providers, each suited for specific use cases. However, some of them, such as StateProvider, StateNotifierProvider, and ChangeNotifierProvider, are considered legacy and will be deprecated in future versions. Therefore, we will not discuss those providers.

Provider

class UserRepository{
Future<List<User>> getUsers(String companyId){...}

Future<User> create(User user) async {...}

Future<User> update(User user) async {...}

Future<void> delete(User user) async {...}
// some methods.
}
// The syntax for defining a provider is as followed:

@rivperpod
UserRepository userRepository(UserRepositoryRef ref){
return UserRepository();
}

Riverpod replaces design patterns such as singletons, service locators, dependency injection, and InheritedWidgets.

FutureProvider

// you can pass arguments to your requests like normal function.
@riverpod
Future<List<User>> user(UserRef ref, String companyId) async {
final users = ref.watch(userRepositoryProvider).getUsers(companyId);
return users;
}

In the code snippets, we fetch user data from the UserRepository and return the user information. You might ask how error and loading states are handled. All these states are natively managed by providers.

https://tenor.com/

Another key feature is that providers are lazy; they will not execute until they are read by the UI at least once. Subsequent reads will not re-execute the network request but will instead return the previously fetched data. If the UI stops using this provider, the cache will be destroyed. Then, if the UI uses the provider again, a new network request will be made.

Let’s see how the UI renders our FutureProvider.

class Home extends ConsumerWidget {
const Home({super.key,required this.id});
String id;
@override
Widget build(BuildContext context) {
final user = ref.watch(userProvider(id));

return Center(
child: switch (user) {
AsyncData(:final value){
return ListView.builder(
itemCount: value.length,
itemBuilder: (context, index) {
return UserWidget(user:value[index]);
});
},
AsyncError() => const Text('Oops, something unexpected happened'),
_ => const CircularProgressIndicator(),
},
);
}
}

We retrieved user info by making HTTP GET requests. But how do we handle changing user data (side-effects) using POST, PUT, or DELETE methods? Let’s explore the AsyncNotifierProvider.

AsyncNotifierProvider

For AsyncNotifierProvider, you can refer to the official documentation. However, it may not be convenient for showing dialogs or snackbars. So, we will demonstrate a different approach.

First, we create an AsyncNotifierProvider to perform our POST request.

@riverpod
class AsyncUserFormNotifier extends _$AsyncUserFormNotifier {

// use fpdart package
// To leverage functional programming concepts and constructs within your Flutter application.
// Fpdart provides a set of functional programming utilities and data types,
// such as Option, Either, Task, and more,
// which can help you write more robust, predictable, and maintainable code.

@override
FutureOr<Option<Unit>> build() => const None();

Future<void> createUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).create(val);

return const Some(unit);
});
}

Future<void> updateUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).update(val);

return const Some(unit);
});
}

Future<void> deleteUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).delete(val);

return const Some(unit);
});
}
}

We need to change our userProvider from a function-based approach to a class-based approach because we need to update our local cache.

@riverpod
class User extends _$User {
@override
Future<List<User>> build(String companyId) async {
final users = ref.watch(userRepositoryProvider).getUsers(companyId);
return users;
}
}

And then, add methods to perform side-effects.

@riverpod
class User extends _$User {
@override
Future<List<User>> build(String companyId) async {
final users = ref.watch(userRepositoryProvider).getUsers(companyId);
return users;
}

Future<void> createUser(User user)async{
// If your backend responds with a user object,
// you should update your local cache to reflect this new data.
final previousValue = state.requireValue;

state = AsyncValue.data([...previousValue, user]);

// Or
// to re-execute the GET request
ref.invalidateSelf();
}

Future<void> updateUser(User user)async{
// If your backend responds with a user object,
// you should update your local cache to reflect this new data.
final previousValue = state.requireValue;

state = AsyncValue.data([
for (final v in previousValue)
if (user.id == user.id) user else v,
]);

// Or
// to re-execute the GET request
ref.invalidateSelf();
}

Future<void> deleteUser(User user)async{
// If your backend responds with a user object,
// you should update your local cache to reflect this new data.
final previousValue = state.requireValue;

state = AsyncValue.data([
for (final v in previousValue.items)
if (v.id != val.id) v,
]);

// Or
// to re-execute the GET request
ref.invalidateSelf();
}
}

Then we add these methods to our AsyncUserFormNotifier.

@riverpod
class AsyncUserFormNotifier extends _$AsyncUserFormNotifier {

// use fpdart package
// To leverage functional programming concepts and constructs within your Flutter application.
// Fpdart provides a set of functional programming utilities and data types,
// such as Option, Either, Task, and more,
// which can help you write more robust, predictable, and maintainable code.

@override
FutureOr<Option<Unit>> build() => const None();

Future<void> createUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).create(val);
// call create method from userProvider to update local data.
ref.read(userProvider.notifier).createUser(user);
return const Some(unit);
});
}

Future<void> updateUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).update(val);
// call update method from userProvider to update local data.
ref.read(userProvider.notifier).updateUser(user);
return const Some(unit);
});
}

Future<void> deleteUser(User val) async {
state = const AsyncValue.loading();

state = await state.guardX(() async {
final user =
await ref.watch(userRepositoryProvider).delete(val);
// call delete method from userProvider to update local data.
ref.read(userProvider.notifier).deleteUser(user);
return const Some(unit);
});
}
}

Then, our UI will look like this.

class Home extends ConsumerWidget {
const Home({super.key,required this.id});
String id;
@override
Widget build(BuildContext context) {
final user = ref.watch(userProvider(id));

// To illustrate state changes using an imperative approach,

ref.listen(asyncUserFormNotifierProvider, (_,state)){
state.unwrapPrevious().maybeWhen(
orElse: () {},
loading: () => LoadingOverlay.show(),
data: (data) {
// replace your widget
LoadingOverlay.hide();
// show snackbar or dialog
},
error: (e, stack) {
// replace your widget
LoadingOverlay.hide();
// show snackbar or dialog
},
);
}

return Center(
child: switch (user) {
AsyncData(:final value){
return ListView.builder(
itemCount: value.length,
itemBuilder: (context, index) {
final user = value[index];
return UserWidget(
user: user,
// call delete method to delete user.
// Notice that we exclusively utilize the ref.read method, as mentioned earlier.
onDelete: () => ref.read(asyncUserFormNotifierProvider.notifer).delete(user);

);
});
},
AsyncError() => const Text('Oops, something unexpected happened'),
_ => const CircularProgressIndicator(),
},
);
}
}

StreamProvider and StreamNotifierProvider

SreamProvider and StreamNotifierProvider are similar to FutureProvider and AsyncNotifierProvider, respectively.

@riverpod
Stream<String> example(ExampleRef ref) async* {
yield 'foo';
}
@riverpod
class Example extends _$Example {
@override
Stream<String> build() async* {
yield 'foo';
}

// Add methods to mutate the state
}

Therefore, we will not explain them in detail. You can refer to the above example and the official documentation.

NotifierProvider

NotifierProvider can be used to mutate state, such as adding a new item to a list, updating the list, deleting an item from a list, and more. It can also be used in form validation, onSaved callbacks to save the current form field state, and can be accessed from anywhere. Let’s see how to define a Notifier.

@riverpod
class Counter extends _$Counter {
@override
int build() => 0;

void increment() => state++;
void decrement() => state--;
}
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {

final counter = ref.watch(counterProvider);
return ElevatedButton(

child: Text('Value: $counter'),

onPressed: () => ref.read(counterProvider.notifier).increment(),
);
}
}

Well, this covers the different types of providers in Riverpod.

Additional Riverpod Features

Provider Lifecycles

So far, we’ve seen how to create/update some state. But we have yet to talk about when state destruction occurs.

Riverpod offers various ways to interact with state disposal. This ranges from delaying the disposal of state to reacting to destruction.

// We can specify "keepAlive" in the annotation to disable
// the automatic state destruction
// It will retain the data indefinitely unless the user closes the app.
@Riverpod(keepAlive: true)
int example(ExampleRef ref) {
return 0;
}

If we don’t specify keepAlive: true, the state will automatically be destroyed when there are no active listeners. For example, if the user leaves the screen, the state will be destroyed, and a new state will be created when the user returns. ( @riverpod and @Riverpod(keepAlive: false) are equivalent. )

But what if we want to hold the state for a specific amount of time? While Riverpod currently doesn’t support this directly, you can achieve it using a Timer and ref.keepAlive. By implementing this logic in an extension method, you can make it reusable.

// Keep in mind that this method only works with AutoDisposeRef, not with Ref.
extension CacheForExtension on AutoDisposeRef<Object?> {
/// Keeps the provider alive for [duration].
void cacheFor(Duration duration) {
// Immediately prevent the state from getting destroyed.
final link = keepAlive();
// After duration has elapsed, we re-enable automatic disposal.
final timer = Timer(duration, link.close);

// Optional: when the provider is recomputed (such as with ref.watch),
// we cancel the pending timer.
onDispose(timer.cancel);
}
}

Then, we can use it like so:

@riverpod
Future<Object> example(ExampleRef ref) async {
/// Keeps the state alive for 5 minutes
ref.cacheFor(const Duration(minutes: 5));

return http.get(Uri.https('example.com'));
}

You can find more information about this in the following article: Flutter Riverpod Data Caching Providers Lifecycle.

Destruction of a provider

Sometimes, you may want to invalidate a provider. This can be done using ref.invalidate, which can be triggered from a widget or another provider. When invoked, it destroys the current state of the provider. Two possible outcomes follow: if the provider has active listeners, a new state will be created; if not, the provider will be fully destroyed.

class Home extends ConsumerWidget {
const Home({super.key,required this.id});
String id;
@override
Widget build(BuildContext context) {
final user = ref.watch(userProvider(id));

return Center(
child: switch (user) {
AsyncData(:final value){...},
AsyncError() => const TextButton(child: Text('Oops, something unexpected happened'),
// here it will destory current state and refetch the data again.
onPressed:()=> ref.invalidate(userProvider(id)))),
_ => const CircularProgressIndicator(),
},
);
}
}

Debouncing and Cancelling network requests

Debouncing a request is a technique used to delay the execution of an action (like sending a request or triggering an event) until a specified time has elapsed since the last input or event. It’s commonly used to optimize performance and prevent unnecessary actions, especially in scenarios with frequent or rapid inputs/events, like typing in a search box or scrolling a page.

Canceling a request involves terminating an ongoing operation before it completes. For example, if the user navigates away from the page before the request finishes. This ensures that you don’t waste time processing a response that the user will never see.


extension DebounceAndCancelExtension on AutoDisposeRef {

/// That client will automatically be closed when the provider is disposed.
Future<DioClient> getDebouncedHttpClient([Duration? duration]) async {
// First, we handle debouncing.
var didDispose = false;
onDispose(() => didDispose = true);

// We delay the request by 500ms, to wait for the user to stop refreshing
// or keep scorlling or keep typing.
await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));

// If the provider was disposed during the delay, it means that the user
// refreshed again. We throw an exception to cancel the request.
// It is safe to use an exception here, as it will be caught by Riverpod.
if (didDispose) {
throw Exception('Cancelled');
}

// We now create the client and close it when the provider is disposed.
final dioClient = watch(dioClientProvider);

onDispose(dioClient.dio.close);

// Finally, we return the client to allow our provider to make the request.
return dioClient;
}
}

The above method is only suitable when you use your Dio object in an AutoDisposeProvider.

When injecting your Dio class into your remote data source, it’s not convenient to handle cancellation. You’ll need to manually create a CancelToken object, pass it to your request, and call cancelToken.cancel in the ref.onDispose method to cancel the request.

For use cases like pagination with search, you may need to debounce requests. Add these lines to your provider:

await Future.delayed(const Duration(milliseconds: 500));
if (cancelToken.isCancelled) {
throw AbortedException();
}

To learn how to implement pagination with search features, and how to debounce and cancel requests, please follow the link.

Scoping Providers

Scoping in Riverpod has various use cases, detailed in the official documentation. We will focus on optimizing widget rebuilds by removing parameters from widget constructors, allowing you to make them const.


// 1. Declare a Provider
final currentProductIndex = Provider<int>((_) => throw UnimplementedError());

class ProductList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.builder(itemBuilder: (context, index) {
// 2. Add a parent ProviderScope
return ProviderScope(
overrides: [
// 3. Add a dependency override on the index
currentProductIndex.overrideWithValue(index),
],
// 4. return a **const** ProductItem with no constructor arguments
child: const ProductItem(),
);
});
}
}

class ProductItem extends ConsumerWidget {
const ProductItem({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
// 5. Access the index via WidgetRef
final index = ref.watch(currentProductIndex);
// do something with the index
}
}

This improves performance because ProductItem can be a const widget in ListView.builder. It will only rebuild if its index changes, even if the ListView rebuilds.

Filtering widget/provider rebuild using “select” and “selectAsync” .

By default, using ref.watch causes consumers/providers to rebuild whenever any property of an object changes. For example, watching a User and only using its "name" will still cause the consumer to rebuild if the "age" changes. To prevent this and only rebuild when specific properties change, we can use the select functionality of providers.


@riverpod
User example(ExampleRef ref) => User(name:"Joe",age: 50);

class ConsumerExample extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {

String name = ref.watch(exampleProvider.select((it) => it.name));
// This will cause the widget to only listen to changes on "name".

return Text('Hello $name');
}
}

The usage of selectAsync is similar to that of select, but it returns a Future instead:

@riverpod
Object? example(ExampleRef ref) async {
// Wait for a user to be available, and listen to only the "name" property
final firstName = await ref.watch(
userProvider.selectAsync((it) => it.name),
);
}

Conclusion

In this article, you’ll see how Riverpod’s flexibility and features help developers create flutter apps more effectively and manage state safely. we cannot cover everything about Riverpod, but it provides enough information for every use case and makes it easy to start with if you have no prior experience. Lastly, I recommend reading the official documentation, following the examples, and joining the Riverpod Discord channels to better understand and use Riverpod effectively. Happy coding!

References

  1. Riverpod Documentation
  2. Code With Andrea

--

--