Master Riverpod in Flutter - Part 1: Understanding Core Concepts [2025]
- AUTHOR
- Ayaan Haaris
- READING TIME
- ~ 27 min read
- PUBLISHED
- Jan 2, 2025
Getting started with Riverpod can be intimidating. You might find yourself asking, "Why are there so many providers?" or "How do I structure my code with Riverpod - where do providers even go?" or "Should I use code generation or not?".
If you've ever faced these questions, you're not alone - I've been there too.
Unfortunately, the official Riverpod documentation doesn't always make it easy to find these answers.
The release of Riverpod 2 brought significant breaking changes from version 1, the introduction of riverpod_annotation
- a new and recommended way of declaring providers, combined with numerous outdated tutorials online, all of this has only added to the confusion.
This article is the first part of a two-part series designed to demystify Riverpod by walking you through the development of a to-do app to showcase its full potential.
I chose a to-do app for a reason: it's simple enough for beginners to grasp, yet complex enough to demonstrate Riverpod's powerful capabilities.
Here's an overview of what we will cover in each part of the series:
- Riverpod Basics: We'll start with the fundamentals, exploring what Riverpod is and how it works under the hood. Understanding different types of providers and their respective use cases. I'll illustrate these concepts with real-world examples to solidify your understanding.
- Building a Todos App: This segment will guide you through the hands-on creation of a todos app using the Async/NotifierProvider. You'll see how the theoretical aspects of Riverpod come to life in a practical application.
By the end of this series, my hope is that for you to have a solid understanding of how Riverpod works and feel confident applying it in your Flutter projects.
I'll do you one better! Why is Riverpod?
If you're familiar with provider or flutter_bloc, you already understand the importance of proper state management in Flutter.
While these solutions have served the community well, Riverpod takes state management to the next level by addressing common pain points and introducing powerful new capabilities.
The Riverpod package was developed from the ground up to solve several frustrating issues that Flutter developers frequently encounter with Provider
.
You might have experienced some of these headaches yourself:
- The infamous
ProviderNotFoundException
when the provider isn't found in the widget tree - Complicated nested provider scenarios leading to boilerplate code
- Context-dependent provider access making testing and reusability challenging
😓
Like other state management solutions, Riverpod offers the essential benefits we've come to expect like: separation of concerns, centralized state management, and reactive UI updates.
However, what makes Riverpod truly stand out is its unique feature set:
- Compile-time safety: Catch errors before your app runs, not during runtime. No more "Provider not found" errors in production!
- Provider independence: Access state anywhere without
context
dependency, making your code cleaner and more flexible - Auto-disposal: Smart memory management that automatically cleans up unused providers, preventing memory leaks
- Family modifiers: Easily parameterize providers for dynamic data needs without complex workarounds
- Built-in caching: Efficient data persistence with minimal boilerplate, perfect for optimizing app performance
- Seamless async handling: First-class support for Future and Stream operations, making async state management a breeze
What's particularly interesting is how Riverpod achieves these features while maintaining a familiar API for developers coming from Provider or other state management solutions.
This makes the transition smoother while unlocking more powerful capabilities.
Understanding Riverpod Timeline
Providers are the building blocks of state management in Riverpod, each serving a specific purpose in your application's architecture.
While Riverpod currently offers eight types of providers, recent changes in version 2 have streamlined which ones you should actually use in your projects.
Let's first look at all the available providers from Riverpod and then focus on the recommended ones for new projects:
Released in version 1:
Provider
FutureProvider
StreamProvider
StateProvider
StateNotifierProvider
ChangeNotifierProvider
Released in version 2:
NotifierProvider
AsyncNotifierProvider
Important Changes and Deprecations
The following providers have been deprecated and replaced in version 2:
1. StateNotifierProvider Deprecation
StateNotifierProvider
is now deprecated in favor of the new NotifierProvider
and AsyncNotifierProvider
released in version 2 of Riverpod.
These new providers offer improved type safety and better integration with Riverpod's latest features.
2. StateProvider Deprecation
According to the official Riverpod documentation (https://riverpod.dev/docs/migration/from_state_notifier):
StateProvider
was exposed by Riverpod since its release, and it was made to save a few LoC for simplified versions ofStateNotifierProvider
. SinceStateNotifierProvider
is deprecated,StateProvider
is to be avoided, too.
3.ChangeNotifierProvider: A Special Case
ChangeNotifierProvider
serves a specific purpose: helping developers transition from the traditional provider package to Riverpod.
While it works, it's not considered a core provider type for new Riverpod applications.
IMPORTANT
These changes are particularly significant to understand because many older tutorials and examples still use StateNotifierProvider
and StateProvider
.
Currently Recommended Providers
Based on the latest version of Riverpod (v2), these are the actively maintained and recommended providers:
Provider
FutureProvider
StreamProvider
NotifierProvider
AsyncNotifierProvider
TIP
When starting a new Flutter project with Riverpod, stick to these five providers. They cover all common use cases and represent the future direction of Riverpod.
NOTE
If you're maintaining or inherit a project that uses deprecated providers, don't panic! They'll continue to work, but consider gradually migrating to the recommended providers during your regular maintenance cycles.
Quick Reference
- ✅
Provider
: For simple immutable values - ✅
FutureProvider
: For async operations that return a single value - ✅
StreamProvider
: For reactive async data streams - ✅
NotifierProvider
: For mutable state management - ✅
AsyncNotifierProvider
: For mutable state with async operations - ❌
StateProvider
: Not recommended (useNotifierProvider
instead) - ❌
StateNotifierProvider
: Deprecated (useNotifierProvider
orAsyncNotifierProvider
instead) - ⚡
ChangeNotifierProvider
: Only for Provider package migration
IMPORTANT
Given the frequent changes in Riverpod's API, including breaking changes, new features, and deprecations, this guide focuses exclusively on the latest implementation patterns.
By covering only the current best practices, my aim is to provide a clear, unambiguous guide without the confusion of legacy approaches.
Each concept is explained with straightforward examples that reflect Riverpod's current recommended usage.
Now that we've covered the current state of Riverpod's providers, let's explore each provider in detail, complete with practical examples and best practices.
NOTE
If this is your first time learning about Riverpod, it can be overwhelming to understand all of these different concepts. But still, i would recommend you to stick with me and continue reading.
Don't worry if you don't get everything right now. Once we build the todo app project on the 2nd part of this series, it will make a lot more sense.
As mentioned before, this first article focuses purely on understanding Riverpod concepts and principles.
We won't cover implementation details like project setup, dependencies, or actual coding yet.
These practical aspects will be covered in Part 2, where we'll build a todo app using the concepts learned here.
This approach allows us to first build a solid theoretical foundation before diving into hands-on development.
ProviderScope: The Root of All Providers
Before diving into providers, there's one crucial setup step: wrapping your app with ProviderScope
.
This widget initializes Riverpod and manages the state of all providers in your application.
void main() {
runApp(
// Enable Riverpod for the entire app
ProviderScope(
child: MyApp(),
),
);
}
Key points to know about ProviderScope
:
- Must be placed at the root of your widget tree
- Handles the creation, maintenance and disposal of providers
- Manages state persistence across widget rebuilds
- Enables provider overrides for testing
We will cover this in more detail in the next part of this series.
Understanding the providers
Lets look at each provider - what they do, how they work and where to use them.
1. Provider
Starting with the most fundamental building block in Riverpod: the Provider
.
Think of Provider
as the simplest, no-frills member of the Riverpod family - it does one thing and does it well: it creates a value and holds onto it.
String appTitle(Ref ref) {
return 'My Awesome App';
}
Let's break down this provider implementation:
@riverpod
: This annotation marks theappTitle
function as a provider so Riverpod can generate the necessary provider code.String
: Specifies the return type of the provider.appTitle
: The provider's name, which will be used to access it throughout the application.Ref ref
: TheProvider
receives aRef
parameter, which can be used to interact with other providers. We'll learn more about it later in the article.
IMPORTANT
The state of a Provider
cannot be changed after it's created - it is immutable. This means once you set a value, you cannot modify it directly.
Setting Up Code Generation
When using the @riverpod
annotation, there are a few additional steps needed to make everything work:
- First, add this line at the top of the file where the provider is declared:
part 'your_file_name.g.dart';
- Then run the build_runner command to generate the necessary provider code:
dart run build_runner build
TIP
During development, you might want to use dart run build_runner watch
instead. This will automatically regenerate the code whenever you save changes to your files.
When the code generation completes, Riverpod will create a provider that you can use throughout your app.
The naming convention is simple - it adds "Provider" to the end of your function name. So our appTitle
function becomes appTitleProvider
:
// Using the generated provider
final title = ref.watch(appTitleProvider);
NOTE
If you're getting errors about missing generated files, double-check that:
- You've added the
part
directive at the top of your file - The file name in the
part
directive matches your actual file name - You've run the build_runner command successfully
Provider
is incredibly useful in several key scenarios:
Caching Computations
When a provider is called, riverpod will execute the provider function and cache the result. It'll keep that value in memory, so we don't have to recalculate it every time.
int calculateValue(Ref ref) {
int value = 0;
value = someExpensiveCalculation();
// Now the value will be cached and will be returned
// immediately if this provider is called again
return value;
}
Dependency injection
Provider
is an excellent choice for dependency injection, the great thing about it is that it doesn't require a context
to access the value.
final todosRepository = ref.read(todoRepositoryProvider);
final dioClient = ref.read(dioClientProvider);
Testing and Override Support
One of Provider
's strengths is its flexibility during testing. It offers a clean way to override values, making your tests more manageable and reliable.
2. FutureProvider
FutureProvider
is the asynchronous sibling of Provider
. While it shares the same characteristics as the regular Provider
- including immutable state and value caching - it's specifically designed to handle asynchronous operations.
Future<Weather> weather(Ref ref) async {
final weatherApi = ref.watch(weatherApiProvider);
return await weatherApi.fetchWeather();
}
This code shows how to create a FutureProvider
that returns weather data asynchronously.
Just like in Provider
, once the async operation is complete, the value will be cached and returned immediately if this provider is called again.
Return type must be Future<Weather>
since we're fetching data. Notice the use of the async
and await
keywords in the provider declaration.
FutureProvider
is very versatile and can be used in several scenarios:
- API Calls and Network Requests
- Complex Async Computations
- Database Operations
NOTE
Unlike regular providers that return direct values, FutureProvider
returns an AsyncValue
. This wrapper provides a clean way to handle loading, error, and data states. We'll cover how to read and handle these states in the Reading Asynchronous Providers section below.
IMPORTANT
FutureProvider
is designed for simple use cases and doesn't provide direct methods to modify the computation after user interaction. For such cases, consider using AsyncNotifierProvider
instead discussed below.
3. StreamProvider
StreamProvider
is like FutureProvider
, but instead of handling one-time future values it handles continuous data streams.
It's perfect for scenarios where you need to work with real-time, continuously updating data.
Stream<int> counter(Ref ref) async* {
int count = 0;
while (true) {
yield count++;
await Future.delayed(const Duration(seconds: 1));
}
}
This example shows a simple StreamProvider
that emits an incrementing number every second.
StreamProvider
is an excellent choice for real-time data handling scenarios like:
- Firebase Realtime Database or Firestore streams
- WebSocket connections
- Live data updates from APIs
- Periodic background operations
Periodic Updates
When you need to refresh data or perform operations at regular intervals:
Stream<Weather> liveWeather(Ref ref) async* {
while (true) {
yield await weatherApi.fetchCurrentWeather();
await Future.delayed(const Duration(minutes: 5));
}
}
TIP
Use async*
and yield
keywords when declaring a StreamProvider
. The async*
marks the function as a stream generator, while yield
is used to emit values into the stream.
WARNING
When implementing a StreamProvider
, remember that it continues running until explicitly cancelled or disposed of.
Just like FutureProvider
, StreamProvider
returns an AsyncValue
wrapper instead of the direct stream value.
4. NotifierProvider
NotifierProvider
is Riverpod's recommended solution for managing mutable state that changes in response to user interactions.
You can think of NotifierProvider
as an alternative to ChangeNotifier
from the provider
package or Cubit
from the flutter_bloc
package.
class Counter extends _$Counter {
int build() => 0; // Initial state
void increment() => state++;
void decrement() => state--;
}
Lets break down what's happening here:
- We use the
@riverpod
annotation to declare aNotifierProvider
. Riverpod will generate the provider code under the class_$<ClassName>
when build command is run. - We extend the generated class
_$Counter
and override thebuild
method that returns the initial state of the provider.int build() => 0; // <State Type> build() => <Initial State>;
state
is a variable provided by theNotifierProvider
class that holds the current state of the provider
IMPORTANT
Don't forget to include <filename>.g.dart
on top and run dart run build_runner build
after creating or modifying NotifierProvider
classes to generate the necessary code.
Mutable State Management
Unlike Provider
, FutureProvider
and StreamProvider
, NotifierProvider
allows you to modify its state, making it perfect for:
- User interaction handling (form inputs, button clicks)
- State updates based on business logic
Centralized state modification
NotifierProvider
helps you keep all related state modifications in one place:
class TodoList extends _$TodoList {
List<Todo> build() => [];
void addTodo(Todo todo) {
state = [...state, todo];
}
void removeTodo(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
Modifying the state
The state
property is only accessible inside the NotifierProvider
class.
To modify the state outside of the NotifierProvider
, you must use methods defined within the notifier class:
// Inside the NotifierProvider class
class TodoList extends _$TodoList {
List<Todo> build() => [];
void addTodo(Todo todo) {
state = [...state, todo];
}
void removeTodo(String id) {
state = state.where((todo) => todo.id != id).toList();
}
}
// Using the notifier methods
ref.read(todoListProvider.notifier).addTodo(newTodo);
ref.read(todoListProvider.notifier).removeTodo(todoId);
IMPORTANT
Never try to modify the state directly from outside the NotifierProvider
. Always create and use methods within the NotifierProvider
class to handle state changes.
This ensures that state changes are handled correctly and consistently within the provider.
Understanding .notifier Access
When working with NotifierProvider
or AsyncNotifierProvider
, you'll often need to access the methods you've defined. This is done using the .notifier
property followed by the method name:
// ❌ This won't work - can't access methods directly
ref.read(todoListProvider).addTodo(newTodo);
// ✅ Correct way - use .notifier to access methods
ref.read(todoListProvider.notifier).addTodo(newTodo);
Why do we need .notifier
? Here's what's happening:
todoListProvider
by itself gives you access to the current stateList<Todo>
todoListProvider.notifier
gives you access to the notifier class instance where your methods are defined
5. AsyncNotifierProvider
AsyncNotifierProvider
is the asynchronous counterpart of NotifierProvider
, designed specifically for managing state that requires asynchronous operations like API calls or database queries.
class UsersProvider extends _$UsersProvider {
FutureOr<List<User>> build() async {
// Initial async state
return await _fetchUsers();
}
Future<void> addUser(User user) async {
await _saveUser(user);
state = AsyncData([...state, user]);
}
Future<void> removeUser(String id) async {
await _deleteUser(id);
state = AsyncData(state.value.where((user) => user.id != id).toList());
}
}
Notice that the build method returns a FutureOr<List<User>>
instead of just List<User>
. This allows for async initialization.
IMPORTANT
AsyncNotifierProvider
provides some useful helper methods to handle different states like AsyncLoading()
, AsyncError()
, and AsyncData()
. We'll explore these in more detail in the Reading the Providers section below.
Procuring the Ref Object
Now that we understand what providers are, let's explore how to actually read their values in our widgets.
You can read/access the providers using the Ref
object. Riverpod provides multiple ways to procure a Ref
object.
ConsumerWidget
ConsumerWidget
is Riverpod's equivalent to Flutter's StatelessWidget
. It allows you to access providers while maintaining the simplicity of stateless widgets.
class UserProfile extends ConsumerWidget {
const UserProfile({super.key});
Widget build(BuildContext context, Ref ref) {
// Using the ref object, we can access the user provider
final user = ref.watch(userProvider);
return Text(user.name);
}
}
NOTE
Notice the additional ref
parameter in the build method. Using this ref
object, you can access the providers.
You might wonder why we need a special widget type instead of using regular Flutter widgets. Here's why:
- Type Safety: ConsumerWidget ensures type-safe access to providers
- Automatic Disposal: Properly handles provider subscriptions when the widget is disposed
- Performance: Optimizes rebuilds to only the widgets that actually depend on the provider
- Dependency Injection: Makes it easy to access providers anywhere in the widget tree
ConsumerStatefulWidget
For widgets that need to maintain state, Riverpod provides ConsumerStatefulWidget
and ConsumerState
.
This works similarly to Flutter's StatefulWidget
but with the ref
object available as a property throughout the entire ConsumerState
class.
class Counter extends ConsumerStatefulWidget {
const Counter({super.key});
ConsumerState<Counter> createState() => _CounterState();
}
class _CounterState extends ConsumerState<Counter> {
int localCount = 0;
Widget build(BuildContext context) {
// Access providers using ref
final globalCount = ref.watch(counterProvider);
return Column(
children: [
Text('Local count: $localCount'),
Text('Global count: $globalCount'),
ElevatedButton(
onPressed: () => setState(() => localCount++),
child: const Text('Increment Local'),
),
],
);
}
}
IMPORTANT
Remember that ref
is available throughout the entire ConsumerState
class, not just in the build method. So you can use it in life-cycle methods like initState
, dispose
etc to read providers using ref.read
.
However, only call ref.watch
inside the build method to avoid unexpected behaviors.
Consumer Builder
Sometimes, you only need to access providers in a small part of your widget tree. This is where Consumer
comes in handy:
class MyWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
const Text('This widget doesn\'t need any provider'),
Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('Count: $count');
},
),
],
);
}
}
When the Consumer
widget is used, it will only rebuild the widgets that are inside the builder
method.
In the example above, the Text
widget that displays the count is the only widget that rebuilds when the counterProvider
changes.
The child
parameter in Consumer
's builder can be used for widgets that don't need to or expensive to rebuild when the provider changes.
Consumer(
child: const ExpensiveWidget(), // This is passed as the child parameter
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('Count: $count'),
child!, // This widget won't rebuild when count changes
],
);
},
)
When to Use Consumer Builder?
- When you only need provider access in a small part of your widget tree
- To optimize rebuilds by limiting them to specific widgets
- When working within existing
StatelessWidget
orStatefulWidget
widgets
Reading the Providers
The Ref
object offers various methods to read provider values. Each method serves a specific purpose, and knowing when to use each one is crucial for building efficient apps.
Reading Synchronous Providers
Synchronous providers like Provider
and NotifierProvider
return direct values.
class Counter extends _$Counter {
int build() => 0;
}
final count = ref.watch(counterProvider);
These values can be accessed in several ways:
ref.watch
The ref.watch()
method is your go-to approach for reading provider values reactively. When a provider's state changes, any widget watching it will automatically rebuild.
class CounterWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// This widget will rebuild whenever the counterProvider value/state changes
final count = ref.watch(counterProvider);
return Text('Current count: $count');
}
}
Use ref.watch()
when you want your UI to automatically update in response to state changes.
ref.watch()
:
Common use cases for - Displaying real-time data
- Building UI elements that depend on state
- Responding to user preferences changes
ref.read
ref.read()
provides a non-reactive way to read a provider's value. It's perfect for one-time operations or event handlers.
class LoginButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// One-time read inside an event handler
final authService = ref.read(authServiceProvider);
authService.login();
},
child: Text('Login'),
);
}
}
WARNING
Never use ref.read()
directly in the build method like we did with the ref.watch()
. This will prevent reactive updates and can lead to stale UI.
ref.read()
:
Common use cases for - Event handlers (onPressed, onTap)
- Non-build methods like
initState
,dispose
etc. - One-time operations
ref.select
ref.select()
allows you to listen to specific parts of a provider's state, preventing unnecessary rebuilds when other parts change.
class UserNameWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// Only rebuilds when username changes
final username = ref.watch(userProfileProvider.select((user) => user.username));
return Text('Welcome, $username!');
}
}
NOTE
Use ref.select()
when dealing with complex state objects where you only need to react to specific property changes.
For example, with a user profile provider:
class UserProfile extends _$UserProfile {
UserProfile build() => UserProfile();
}
// Only rebuild when email changes
final email = ref.watch(userProfileProvider.select((user) => user.email));
// Only rebuild when address changes
final address = ref.watch(userProfileProvider.select((user) => user.address));
NOTE
While ref.select()
adds a bit more code, it can significantly improve performance in larger applications by reducing unnecessary rebuilds.
ref.select()
:
Common use cases for - Large state objects where you only need specific fields
- Lists where you only care about certain items
- Complex objects with frequently changing properties
You can learn more about ref.select()
here.
Reading Asynchronous Providers
Asynchronous providers (FutureProvider
, StreamProvider
, and AsyncNotifierProvider
) return an AsyncValue
type that requires special handling.
class UsersProvider extends _$UsersProvider {
Future<List<User>> build() async => await api.getUsers();
}
// The users provider returns an AsyncValue<List<User>>
AsyncValue<List<User>> users = ref.watch(usersProvider);
Understanding AsyncValue
AsyncValue
is a core concept in Riverpod that helps handle asynchronous operations elegantly. It's used by FutureProvider
, StreamProvider
, and AsyncNotifierProvider
to represent the different states of async operations.
What is AsyncValue?
AsyncValue
is a union type that can be in one of three states:
AsyncValue<T> {
AsyncData<T> // Successfully loaded data
AsyncError // Error occurred during loading
AsyncLoading // Currently loading
}
Lets see an example with a FutureProvider
:
Future<List<User>> usersProvider(Ref ref) async {
List<User> users = await api.getUsers();
return users;
}
By default, the returned value from usersProvider
will be of AsyncValue<List<User>>
type.
AsyncValue<List<User>> users = ref.watch(usersProvider);
The most common way to handle AsyncValue
is using the when
method.
Reading with .when()
class UserListWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// Watching the users provider. This will start the network request
// if it wasn't already started. By using ref.watch, this widget
// will rebuild whenever the usersProvider updates.
final users = ref.watch(usersProvider);
// Using the when method to handle different states
return users.when(
data: (users) => UserList(users),
error: (error, stack) => ErrorWidget(error.toString()),
loading: () => CircularProgressIndicator(),
);
}
}
After the ref.watch
call, the usersProvider
will start the network request if it wasn't already started.
- First the state will be
loading
, soCircularProgressIndicator
will be shown. - Then it will be
data
with the list of users, soUserList
will be shown. - If the network request fails, the state will be
error
, soErrorWidget
will be shown.
NOTE
The when
method is a powerful tool for handling different states of AsyncValue
. It allows you to provide different widgets for each state and ensures that only the relevant widgets are rebuilt when the state changes.
Reading with .whenData()
When you only care about the data and want to provide a default value for loading/error states:
final users = ref.watch(usersProvider).whenData((users) => users);
There are other methods like .whenOrNull
, .whenDataOrNull
etc. that you can use to handle null values.
And for error handling, you can use .whenError
or .whenDataError
to handle errors differently.
Reading with .maybeWhen()
Allows you to handle specific states while providing a default for others:
ref.watch(usersProvider).maybeWhen(
data: (users) => UserList(users),
loading: () => CircularProgressIndicator(),
// Default case for error or any other state
orElse: () => Text('Something went wrong'),
);
AsyncValue Helpers
If you are working with AsyncNotifierProvider
, you can granularly control the states of your provider using AsyncLoading()
, AsyncData()
and AsyncError()
methods.
class UsersProvider extends _$UsersProvider {
Future<List<User>> build() async => await fetchUsers();
Future<void> fetchUsers() async {
state = AsyncLoading(); // Set the state to loading
try {
final users = await api.getUsers();
state = AsyncData(users); // Set the state to data
} catch (e) {
state = AsyncError(e); // Set the state to error
}
}
}
Using AsyncGuard
AsyncValue.guard
is a combination of AsyncData
and AsyncError
. This is useful to avoid having to do a tedious try/catch
block.
The above example can be rewritten as:
class UsersProvider extends _$UsersProvider {
Future<List<User>> build() async => await fetchUsers();
Future<void> fetchUsers() async {
state = AsyncLoading();
// This will set the state to either AsyncData or AsyncError
state = await AsyncValue.guard(() => api.getUsers());
}
}
Preserving Previous Data
copyWithPrevious
is a useful method for preserving previous data while loading new data.
fetchUsers() async {
// Keep showing old data while loading new data
state = state.copyWithPrevious(
AsyncValue.loading()
);
final users = await api.getUsers();
state = AsyncData(users);
}
Best Practices
- Always handle all possible states (loading, error, data)
- Consider preserving previous data during refreshes
- Implement proper error recovery mechanisms
- Show appropriate loading states to users
Provider Lifecycle
When working with providers, you often need to control their lifecycle - when they should be created, disposed, or recreated with different parameters.
Riverpod offers powerful modifiers to handle these scenarios efficiently.
Disposing Providers
By default, Riverpod automatically disposes provider instances when they are no longer being listened to.
However, any resources (streams, timers, controllers etc.) created within the provider must be manually disposed.
You can handle resource cleanup using ref.onDispose
which will be called when the provider is disposed:
Stream<ChatMessage> chatMessages(Ref ref, String roomId) {
final socket = WebSocket('wss://chat.example.com/$roomId');
// Clean up resources when provider is disposed
ref.onDispose(() {
socket.close();
});
return socket.messages;
}
Common resources requiring manual cleanup:
- WebSocket connections
- Stream controllers
- Timer instances
- API request cancellation tokens
- Animation controllers
Let's see some practical examples:
Example 1: API Request Cancellation
Future<List<Product>> searchProducts(Ref ref, String query) async {
// Create a cancellation token
final cancelToken = CancelToken();
// Register cleanup for the cancel token
ref.onDispose(() {
cancelToken.cancel();
});
// Pass the token to your API call
return await api.searchProducts(
query,
cancelToken: cancelToken
);
}
Example 2: WebSocket Connection
Stream<ChatMessage> chatMessages(Ref ref, String roomId) {
final socket = WebSocket('wss://chat.example.com/$roomId');
// Register cleanup for the WebSocket
ref.onDispose(() {
socket.close(); // Explicitly close the WebSocket connection
});
return socket.messages.map((data) => ChatMessage.fromJson(data));
}
Example 3: Custom Controller
class Counter extends _$Counter {
late final Timer _timer;
int build() {
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => state++,
);
// Clean up the timer when the provider is disposed
ref.onDispose(() {
_timer.cancel();
});
return 0;
}
}
KeepAlive
Sometimes you may want to preserve state even when no widgets are actively listening to a provider.
This kind of behavior is more common than you think.
Following are some common use cases where you may want to keep a provider alive:
- Global app settings
- User authentication state
- Cached API responses
- Theme preferences
To prevent auto-disposal of a provider, you can use the keepAlive
parameter:
// Notice the riverpod annotation starts with capital 'R'
(keepAlive: true)
class Auth extends _$Auth {
AuthState build() => const AuthState.initial();
Future<void> login(String username, String password) async {
// Login state will persist even when no widgets are listening
state = const AuthState.loading();
try {
final user = await _authService.login(username, password);
state = AuthState.authenticated(user);
} catch (e) {
state = AuthState.error(e.toString());
}
}
}
If you want to pass parameters to riverpod annotation, you need to use capital 'R' in the annotation as @Riverpod()
.
WARNING
Use keepAlive
sparingly! Each kept-alive provider continues to consume memory. Only use it for truly global state that needs to persist throughout the app's lifecycle.
Passing Parameters to Providers
Riverpod 2 greatly simplified how we pass parameters to providers. You can now pass parameters directly like regular Dart functions.
Basic Parameter Passing
String greeting(Ref ref, {String name = 'Guest'}) {
return 'Hello, $name!';
}
// Usage:
final greeting1 = ref.watch(greetingProvider(name: 'John')); // "Hello, John!"
final greeting2 = ref.watch(greetingProvider()); // "Hello, Guest!"
Multiple Parameters
Future<List<Product>> filteredProducts(
Ref ref, {
required String category,
String? searchQuery,
int page = 1,
}) async {
return await api.getProducts(
category: category,
query: searchQuery,
page: page,
);
}
// Usage:
final products = ref.watch(filteredProductsProvider(
category: 'electronics',
searchQuery: 'phone',
page: 2,
));
Complex Objects as Parameters
Future<OrderDetails> orderDetails(Ref ref, Order order) async {
return await api.getOrderDetails(order.id);
}
Once the orderDetailsProvider
is created, it will be reused for the same Order
object. It will only be recomputed if a different Order
object is passed.
TIP
When passing objects as parameters, make sure they properly implement ==
and hashCode
. This ensures the provider correctly determines when to rebuild based on parameter changes.
What's Next?
We've covered a lot of ground in this first part! Let's quickly recap what we learned:
- The evolution of Riverpod and why certain providers are now recommended over others
- The five core providers you should focus on in new projects:
Provider
for simple immutable valuesFutureProvider
for one-time async operationsStreamProvider
for reactive data streamsNotifierProvider
for mutable stateAsyncNotifierProvider
for mutable async state
- How to read providers using
ref.watch
,ref.read
, andref.select
- Handling async states with
AsyncValue
- Managing provider lifecycles with auto-disposal and parameters
TIP
If some concepts still feel unclear, don't worry! The next part will make everything click as we build the todos app.
In Part 2: Building a Riverpod Todo App, we'll put these concepts into practice by building a simple but practical Todo app. You'll learn:
- Setting up a new Flutter project with Riverpod
- Creating and consuming providers using the code generation approach
- Managing todos state with
NotifierProvider
andAsyncNotifierProvider
- Building a responsive UI that updates automatically with state changes
- Understanding provider dependencies in a real application
- Implementing error handling with
ErrorWidget
- Repository pattern for better code organization
Ready to start building? Let's dive into the Todo app tutorial →